Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,40 @@ agentapi server --allowed-origins 'https://example.com,http://localhost:3000' --
AGENTAPI_ALLOWED_ORIGINS='https://example.com http://localhost:3000' agentapi server -- claude
```

#### MCP configuration (experimental)

When using the experimental ACP transport (`--experimental-acp`), you can provide MCP servers to the agent via a JSON configuration file using the `--mcp-file` flag.

The file uses the same format as Claude's MCP configuration:

```json
{
"mcpServers": {
"type": "stdio",
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"],
"env": {
"DEBUG": "true"
}
},
"api-server": {
"type": "http",
"url": "https://api.example.com/mcp",
"headers": {
"Authorization": "Bearer token"
}
}
}
}
```

Example usage:

```bash
agentapi server --experimental-acp --mcp-file ./mcp.json -- claude
```

### `agentapi attach`

Attach to a running agent's terminal session.
Expand Down
8 changes: 8 additions & 0 deletions cmd/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er
}

experimentalACP := viper.GetBool(FlagExperimentalACP)
mcpFile := viper.GetString(FlagMCPFile)

if mcpFile != "" && !experimentalACP {
return xerrors.Errorf("--mcp-file requires --experimental-acp")
}

if experimentalACP && (saveState || loadState) {
return xerrors.Errorf("ACP mode doesn't support state persistence")
Expand Down Expand Up @@ -169,6 +174,7 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er
acpResult, err = httpapi.SetupACP(ctx, httpapi.SetupACPConfig{
Program: agent,
ProgramArgs: argsToPass[1:],
MCPFilePath: mcpFile,
})
if err != nil {
return xerrors.Errorf("failed to setup ACP: %w", err)
Expand Down Expand Up @@ -382,6 +388,7 @@ const (
FlagSaveState = "save-state"
FlagPidFile = "pid-file"
FlagExperimentalACP = "experimental-acp"
FlagMCPFile = "mcp-file"
)

func CreateServerCmd() *cobra.Command {
Expand Down Expand Up @@ -425,6 +432,7 @@ func CreateServerCmd() *cobra.Command {
{FlagSaveState, "", false, "Save state to state-file on shutdown (defaults to true when state-file is set)", "bool"},
{FlagPidFile, "", "", "Path to file where the server process ID will be written for shutdown scripts", "string"},
{FlagExperimentalACP, "", false, "Use experimental ACP transport instead of PTY", "bool"},
{FlagMCPFile, "", "", "MCP file for the ACP server", "string"},
}

for _, spec := range flagSpecs {
Expand Down
52 changes: 52 additions & 0 deletions cmd/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -799,3 +799,55 @@ func TestServerCmd_AllowedOrigins(t *testing.T) {
})
}
}

func TestServerCmd_MCPFileFlag(t *testing.T) {
t.Run("mcp-file default is empty", func(t *testing.T) {
isolateViper(t)

serverCmd := CreateServerCmd()
setupCommandOutput(t, serverCmd)
serverCmd.SetArgs([]string{"--exit", "dummy-command"})
err := serverCmd.Execute()
require.NoError(t, err)

assert.Equal(t, "", viper.GetString(FlagMCPFile))
})

t.Run("mcp-file can be set via CLI flag", func(t *testing.T) {
isolateViper(t)

serverCmd := CreateServerCmd()
setupCommandOutput(t, serverCmd)
serverCmd.SetArgs([]string{"--mcp-file", "/path/to/mcp.json", "--exit", "dummy-command"})
err := serverCmd.Execute()
require.NoError(t, err)

assert.Equal(t, "/path/to/mcp.json", viper.GetString(FlagMCPFile))
})

t.Run("mcp-file can be set via environment variable", func(t *testing.T) {
isolateViper(t)
t.Setenv("AGENTAPI_MCP_FILE", "/env/path/to/mcp.json")

serverCmd := CreateServerCmd()
setupCommandOutput(t, serverCmd)
serverCmd.SetArgs([]string{"--exit", "dummy-command"})
err := serverCmd.Execute()
require.NoError(t, err)

assert.Equal(t, "/env/path/to/mcp.json", viper.GetString(FlagMCPFile))
})

t.Run("CLI flag overrides environment variable", func(t *testing.T) {
isolateViper(t)
t.Setenv("AGENTAPI_MCP_FILE", "/env/path/to/mcp.json")

serverCmd := CreateServerCmd()
setupCommandOutput(t, serverCmd)
serverCmd.SetArgs([]string{"--mcp-file", "/cli/path/to/mcp.json", "--exit", "dummy-command"})
err := serverCmd.Execute()
require.NoError(t, err)

assert.Equal(t, "/cli/path/to/mcp.json", viper.GetString(FlagMCPFile))
})
}
38 changes: 38 additions & 0 deletions e2e/echo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -556,3 +556,41 @@ func getFreePort() (int, error) {

return l.Addr().(*net.TCPAddr).Port, nil
}

func TestServerFlagValidation(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}

t.Run("mcp-file requires experimental-acp", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
defer cancel()

binaryPath := os.Getenv("AGENTAPI_BINARY_PATH")
if binaryPath == "" {
cwd, err := os.Getwd()
require.NoError(t, err, "Failed to get current working directory")
binaryPath = filepath.Join(cwd, "..", "out", "agentapi")
t.Logf("Building binary at %s", binaryPath)
buildCmd := exec.CommandContext(ctx, "go", "build", "-o", binaryPath, ".")
buildCmd.Dir = filepath.Join(cwd, "..")
require.NoError(t, buildCmd.Run(), "Failed to build binary")
}

// Create a temporary MCP file
tmpDir := t.TempDir()
mcpFile := filepath.Join(tmpDir, "mcp.json")
err := os.WriteFile(mcpFile, []byte(`{"mcpServers": {}}`), 0o644)
require.NoError(t, err, "Failed to create temp MCP file")

// Run the server with --mcp-file but WITHOUT --experimental-acp
cmd := exec.CommandContext(ctx, binaryPath, "server",
"--mcp-file", mcpFile,
"--", "echo", "test")

output, err := cmd.CombinedOutput()
require.Error(t, err, "Expected server to fail when --mcp-file is used without --experimental-acp")
require.Contains(t, string(output), "--mcp-file requires --experimental-acp",
"Expected error message about --mcp-file requiring --experimental-acp, got: %s", string(output))
})
}
3 changes: 2 additions & 1 deletion lib/httpapi/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ func SetupProcess(ctx context.Context, config SetupProcessConfig) (*termexec.Pro
type SetupACPConfig struct {
Program string
ProgramArgs []string
MCPFilePath string
Clock quartz.Clock
}

Expand Down Expand Up @@ -88,7 +89,7 @@ func SetupACP(ctx context.Context, config SetupACPConfig) (*SetupACPResult, erro
return nil, fmt.Errorf("failed to start process: %w", err)
}

agentIO, err := acpio.NewWithPipes(ctx, stdin, stdout, logger, os.Getwd)
agentIO, err := acpio.NewWithPipes(ctx, stdin, stdout, logger, os.Getwd, config.MCPFilePath)
if err != nil {
_ = cmd.Process.Kill()
return nil, fmt.Errorf("failed to initialize ACP connection: %w", err)
Expand Down
60 changes: 58 additions & 2 deletions x/acpio/acpio.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ package acpio

import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"strings"
"sync"

acp "github.com/coder/acp-go-sdk"
st "github.com/coder/agentapi/lib/screentracker"
"golang.org/x/xerrors"
)

// Compile-time assertion that ACPAgentIO implements st.AgentIO
Expand Down Expand Up @@ -131,7 +134,7 @@ func (a *ACPAgentIO) SetOnChunk(fn func(chunk string)) {
}

// NewWithPipes creates an ACPAgentIO connected via the provided pipes
func NewWithPipes(ctx context.Context, toAgent io.Writer, fromAgent io.Reader, logger *slog.Logger, getwd func() (string, error)) (*ACPAgentIO, error) {
func NewWithPipes(ctx context.Context, toAgent io.Writer, fromAgent io.Reader, logger *slog.Logger, getwd func() (string, error), mcpFilePath string) (*ACPAgentIO, error) {
if logger == nil {
logger = slog.Default()
}
Expand All @@ -154,6 +157,12 @@ func NewWithPipes(ctx context.Context, toAgent io.Writer, fromAgent io.Reader, l
}
logger.Debug("ACP initialized", "protocolVersion", initResp.ProtocolVersion)

// Prepare the MCPs for the session
supportedMCPList, err := getSupportedMCPConfig(mcpFilePath, logger, &initResp)
if err != nil {
return nil, err
}

// Create a session
cwd, err := getwd()
if err != nil {
Expand All @@ -162,7 +171,7 @@ func NewWithPipes(ctx context.Context, toAgent io.Writer, fromAgent io.Reader, l
}
sessResp, err := conn.NewSession(ctx, acp.NewSessionRequest{
Cwd: cwd,
McpServers: []acp.McpServer{},
McpServers: supportedMCPList,
})
if err != nil {
logger.Error("Failed to create ACP session", "error", err)
Expand All @@ -174,6 +183,53 @@ func NewWithPipes(ctx context.Context, toAgent io.Writer, fromAgent io.Reader, l
return agentIO, nil
}

func getSupportedMCPConfig(mcpFilePath string, logger *slog.Logger, initResp *acp.InitializeResponse) ([]acp.McpServer, error) {
if mcpFilePath == "" {
return []acp.McpServer{}, nil
}

mcpFile, err := os.Open(mcpFilePath)
if err != nil {
return nil, xerrors.Errorf("failed to open mcp file: %w", err)
}

defer func() {
if closeErr := mcpFile.Close(); closeErr != nil {
logger.Error("Failed to close mcp file", "path", mcpFilePath, "error", closeErr)
}
}()

var mcpConfig AgentapiMcpConfig
decoder := json.NewDecoder(mcpFile)

if err = decoder.Decode(&mcpConfig); err != nil {
return nil, xerrors.Errorf("failed to decode mcp file: %w", err)
}

// Convert MCP format to ACP format and filter by agent capabilities
var supportedMCPList []acp.McpServer
for name, server := range mcpConfig.McpServers {
mcpServer, err := server.convertAgentapiMcpToAcp(name)
if err != nil {
logger.Warn("Skipping invalid MCP server", "name", name, "error", err)
continue
}

// Filter based on agent capabilities
if mcpServer.Http != nil && !initResp.AgentCapabilities.McpCapabilities.Http {
logger.Debug("Skipping HTTP MCP server (agent doesn't support HTTP)", "name", name)
continue
}
if mcpServer.Sse != nil && !initResp.AgentCapabilities.McpCapabilities.Sse {
logger.Debug("Skipping SSE MCP server (agent doesn't support SSE)", "name", name)
continue
}

supportedMCPList = append(supportedMCPList, mcpServer)
}
return supportedMCPList, nil
}

// Write sends a message to the agent via ACP prompt
func (a *ACPAgentIO) Write(data []byte) (int, error) {
text := string(data)
Expand Down
1 change: 1 addition & 0 deletions x/acpio/acpio_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ func newTestPair(t *testing.T, agent *testAgent) *acpio.ACPAgentIO {
clientToAgentW, agentToClientR,
nil,
func() (string, error) { return os.TempDir(), nil },
"", // no MCP file
)
require.NoError(t, err)

Expand Down
85 changes: 85 additions & 0 deletions x/acpio/mcp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package acpio

import (
"slices"

"github.com/coder/acp-go-sdk"
"golang.org/x/xerrors"
)

// AgentapiMcpConfig represents the Claude MCP JSON format where mcpServers is a map
// with server names as keys.
type AgentapiMcpConfig struct {
McpServers map[string]AgentapiMcpServer `json:"mcpServers"`
}

// AgentapiMcpServer represents a single MCP server in Claude's format.
type AgentapiMcpServer struct {
// Type can be "stdio", "sse" or "http"
Type string `json:"type"`
// Stdio transport fields
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
// HTTP | SSE transport fields
URL string `json:"url,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
}

// convertAgentapiMcpToAcp converts a Claude MCP server config to the ACP format.
func (a *AgentapiMcpServer) convertAgentapiMcpToAcp(name string) (acp.McpServer, error) {
serverType := a.Type
acpMCPServer := acp.McpServer{}

if serverType == "stdio" {
if a.Command == "" {
return acp.McpServer{}, xerrors.Errorf("stdio server %q missing command", name)
}
// Convert env map to []EnvVariable
var envVars []acp.EnvVariable
for key, value := range a.Env {
envVars = append(envVars, acp.EnvVariable{
Name: key,
Value: value,
})
}

acpMCPServer.Stdio = &acp.McpServerStdio{
Name: name,
Command: a.Command,
Args: a.Args,
Env: envVars,
}
} else if slices.Contains([]string{"http", "sse"}, serverType) {
if a.URL == "" {
return acp.McpServer{}, xerrors.Errorf("http server %q missing url", name)
}
// Convert headers map to []HttpHeader
var headers []acp.HttpHeader
for key, value := range a.Headers {
headers = append(headers, acp.HttpHeader{
Name: key,
Value: value,
})
}

if serverType == "sse" {
acpMCPServer.Sse = &acp.McpServerSse{
Name: name,
Type: "sse",
Url: a.URL,
Headers: headers,
}
} else {
acpMCPServer.Http = &acp.McpServerHttp{
Name: name,
Type: "http",
Url: a.URL,
Headers: headers,
}
}
} else {
return acp.McpServer{}, xerrors.Errorf("unsupported server type %q for server %q", serverType, name)
}
return acpMCPServer, nil
}
Loading
Loading