-
Notifications
You must be signed in to change notification settings - Fork 115
feat: add MCP file support for ACP server configuration #214
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a9637f6
f607202
2fbafff
12ecfa4
8183c5f
bf48373
28158f3
a9d80dc
f31782b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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) | ||||||
|
||||||
| err := os.WriteFile(mcpFile, []byte(`{"mcpServers": []}`), 0o644) | |
| err := os.WriteFile(mcpFile, []byte(`{"mcpServers": {}}`), 0o644) |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||||||||||
|
|
@@ -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
|
||||||||||||||||||
| if logger == nil { | ||||||||||||||||||
| logger = slog.Default() | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
@@ -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 { | ||||||||||||||||||
|
|
@@ -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) | ||||||||||||||||||
|
|
@@ -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) | ||||||||||||||||||
| } | ||||||||||||||||||
35C4n0r marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||
|
|
||||||||||||||||||
| 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) | ||||||||||||||||||
| } | ||||||||||||||||||
|
||||||||||||||||||
| } | |
| } | |
| 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) | |
| } |
35C4n0r marked this conversation as resolved.
Show resolved
Hide resolved
| 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
|
||
|
|
||
| // 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
|
||
| 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
|
||
| 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
|
||
| 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) | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.