Skip to content
Draft
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,39 @@ 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": {
"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)
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test writes an MCP file with "mcpServers": [], but the documented/implemented format expects mcpServers to be an object/map. Even though the server should fail before parsing the file in this test, using a structurally valid example (e.g., an empty object) will keep the test aligned with the expected format and reduce future brittleness.

Suggested change
err := os.WriteFile(mcpFile, []byte(`{"mcpServers": []}`), 0o644)
err := os.WriteFile(mcpFile, []byte(`{"mcpServers": {}}`), 0o644)

Copilot uses AI. Check for mistakes.
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) {
Comment on lines 136 to +137
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NewWithPipes is a public constructor and this change adds a new required parameter, which is a breaking API change for any external users of x/acpio. To preserve compatibility, consider keeping the old signature (defaulting MCP file to empty) and adding a new constructor or options-based parameter for MCP support.

Copilot uses AI. Check for mistakes.
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)
}
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

json.Decoder.Decode reads only the first JSON value; if the file has trailing non-whitespace content (e.g., accidental concatenated JSON), it will be silently ignored. Consider validating that the decoder reaches EOF after the first decode to avoid accepting malformed config files.

Suggested change
}
}
if err = decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return nil, xerrors.Errorf("failed to decode mcp file: trailing data after first JSON value")
}
return nil, xerrors.Errorf("failed to decode mcp file: invalid trailing content: %w", err)
}

Copilot uses AI. Check for mistakes.

// 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 (
"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"`
}
Comment on lines +8 to +12
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AgentapiMcpConfig is exported but only used internally (within x/acpio). Exporting it expands the public API surface unnecessarily. Consider unexporting this type (and related ones) unless there’s a clear external use case.

Copilot uses AI. Check for mistakes.

// AgentapiMcpServer represents a single MCP server in Claude's format.
type AgentapiMcpServer struct {
// Type can be "stdio" or "http". Defaults to "stdio" if not specified.
Type string `json:"type,omitempty"`
// Stdio transport fields
Comment on lines +14 to +18
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AgentapiMcpServer is also exported but appears to be an internal decoding/translation struct. If it’s not meant for external callers, unexport it; if it is public, consider Go initialism casing (e.g., AgentAPIMCPServer) to match typical exported-type conventions.

Copilot uses AI. Check for mistakes.
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
// HTTP 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
if serverType == "" {
// Default to stdio if no type specified and command is present
if a.Command != "" {
serverType = "stdio"
} else if a.URL != "" {
serverType = "http"
}
}

switch serverType {
case "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,
})
}
Comment on lines +44 to +51
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The env map is converted to a slice using Go map iteration, which is intentionally randomized. This can lead to nondeterministic ordering of Env entries between runs, making session payloads unstable and potentially causing flaky tests/behavior if ordering matters downstream. Consider sorting the keys before appending to produce a stable Env order.

Copilot uses AI. Check for mistakes.
return acp.McpServer{
Stdio: &acp.McpServerStdio{
Name: name,
Command: a.Command,
Args: a.Args,
Env: envVars,
},
}, nil

case "http":
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,
})
}
Comment on lines +65 to +72
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as Env: converting the headers map to a slice via map iteration yields nondeterministic header ordering. If the ACP server treats headers as an ordered list (or logs/compares requests), this can create unstable behavior. Sort header keys before building the slice to keep ordering deterministic.

Copilot uses AI. Check for mistakes.
return acp.McpServer{
Http: &acp.McpServerHttp{
Name: name,
Type: "http",
Url: a.URL,
Headers: headers,
},
}, nil

default:
return acp.McpServer{}, xerrors.Errorf("unsupported server type %q for server %q", serverType, name)
}
}
Loading
Loading