diff --git a/README.md b/README.md index e6a75618..22a40e52 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cmd/server/server.go b/cmd/server/server.go index cc53aa17..b2ceef3e 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -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") @@ -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) @@ -382,6 +388,7 @@ const ( FlagSaveState = "save-state" FlagPidFile = "pid-file" FlagExperimentalACP = "experimental-acp" + FlagMCPFile = "mcp-file" ) func CreateServerCmd() *cobra.Command { @@ -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 { diff --git a/cmd/server/server_test.go b/cmd/server/server_test.go index 29eb65b4..30846b4e 100644 --- a/cmd/server/server_test.go +++ b/cmd/server/server_test.go @@ -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)) + }) +} diff --git a/e2e/echo_test.go b/e2e/echo_test.go index 1ef567a0..65c2bdd8 100644 --- a/e2e/echo_test.go +++ b/e2e/echo_test.go @@ -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)) + }) +} diff --git a/lib/httpapi/setup.go b/lib/httpapi/setup.go index b505d533..d4eb7e18 100644 --- a/lib/httpapi/setup.go +++ b/lib/httpapi/setup.go @@ -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 } @@ -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) diff --git a/x/acpio/acpio.go b/x/acpio/acpio.go index 1a440132..d1bc2e0f 100644 --- a/x/acpio/acpio.go +++ b/x/acpio/acpio.go @@ -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) { 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) + } + + 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) diff --git a/x/acpio/acpio_test.go b/x/acpio/acpio_test.go index 2e91c547..728c2f27 100644 --- a/x/acpio/acpio_test.go +++ b/x/acpio/acpio_test.go @@ -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) diff --git a/x/acpio/mcp.go b/x/acpio/mcp.go new file mode 100644 index 00000000..2a4ac9f6 --- /dev/null +++ b/x/acpio/mcp.go @@ -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 +} diff --git a/x/acpio/mcp_internal_test.go b/x/acpio/mcp_internal_test.go new file mode 100644 index 00000000..9f81bb9b --- /dev/null +++ b/x/acpio/mcp_internal_test.go @@ -0,0 +1,321 @@ +package acpio + +import ( + "log/slog" + "os" + "path/filepath" + "testing" + + acp "github.com/coder/acp-go-sdk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetSupportedMCPConfig(t *testing.T) { + logger := slog.Default() + + t.Run("empty file path returns empty slice", func(t *testing.T) { + initResp := &acp.InitializeResponse{} + result, err := getSupportedMCPConfig("", logger, initResp) + require.NoError(t, err) + assert.Empty(t, result) + }) + + t.Run("file not found returns error", func(t *testing.T) { + initResp := &acp.InitializeResponse{} + _, err := getSupportedMCPConfig("/nonexistent/path/mcp.json", logger, initResp) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to open mcp file") + }) + + t.Run("invalid JSON returns error", func(t *testing.T) { + tmpDir := t.TempDir() + mcpFile := filepath.Join(tmpDir, "invalid.json") + err := os.WriteFile(mcpFile, []byte("not valid json"), 0o644) + require.NoError(t, err) + + initResp := &acp.InitializeResponse{} + _, err = getSupportedMCPConfig(mcpFile, logger, initResp) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to decode mcp file") + }) + + t.Run("stdio servers always included", func(t *testing.T) { + tmpDir := t.TempDir() + mcpFile := filepath.Join(tmpDir, "mcp.json") + // Claude MCP format: mcpServers is a map with server name as key + mcpContent := `{ + "mcpServers": { + "test-stdio": { + "command": "/usr/bin/test", + "args": ["--stdio"], + "env": { + "DEBUG": "true" + } + } + } + }` + err := os.WriteFile(mcpFile, []byte(mcpContent), 0o644) + require.NoError(t, err) + + initResp := &acp.InitializeResponse{ + AgentCapabilities: acp.AgentCapabilities{ + McpCapabilities: acp.McpCapabilities{ + Http: false, + Sse: false, + }, + }, + } + result, err := getSupportedMCPConfig(mcpFile, logger, initResp) + require.NoError(t, err) + assert.Len(t, result, 1) + assert.NotNil(t, result[0].Stdio) + assert.Equal(t, "test-stdio", result[0].Stdio.Name) + assert.Equal(t, "/usr/bin/test", result[0].Stdio.Command) + assert.Equal(t, []string{"--stdio"}, result[0].Stdio.Args) + // Check env was converted correctly + assert.Len(t, result[0].Stdio.Env, 1) + assert.Equal(t, "DEBUG", result[0].Stdio.Env[0].Name) + assert.Equal(t, "true", result[0].Stdio.Env[0].Value) + }) + + t.Run("http servers filtered when capability is false", func(t *testing.T) { + tmpDir := t.TempDir() + mcpFile := filepath.Join(tmpDir, "mcp.json") + mcpContent := `{ + "mcpServers": { + "test-http": { + "type": "http", + "url": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer token123" + } + } + } + }` + err := os.WriteFile(mcpFile, []byte(mcpContent), 0o644) + require.NoError(t, err) + + initResp := &acp.InitializeResponse{ + AgentCapabilities: acp.AgentCapabilities{ + McpCapabilities: acp.McpCapabilities{ + Http: false, + Sse: false, + }, + }, + } + result, err := getSupportedMCPConfig(mcpFile, logger, initResp) + require.NoError(t, err) + assert.Empty(t, result) + }) + + t.Run("http servers included when capability is true", func(t *testing.T) { + tmpDir := t.TempDir() + mcpFile := filepath.Join(tmpDir, "mcp.json") + mcpContent := `{ + "mcpServers": { + "test-http": { + "type": "http", + "url": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer token123" + } + } + } + }` + err := os.WriteFile(mcpFile, []byte(mcpContent), 0o644) + require.NoError(t, err) + + initResp := &acp.InitializeResponse{ + AgentCapabilities: acp.AgentCapabilities{ + McpCapabilities: acp.McpCapabilities{ + Http: true, + Sse: false, + }, + }, + } + result, err := getSupportedMCPConfig(mcpFile, logger, initResp) + require.NoError(t, err) + assert.Len(t, result, 1) + assert.NotNil(t, result[0].Http) + assert.Equal(t, "test-http", result[0].Http.Name) + assert.Equal(t, "https://example.com/mcp", result[0].Http.Url) + // Check headers were converted correctly + assert.Len(t, result[0].Http.Headers, 1) + assert.Equal(t, "Authorization", result[0].Http.Headers[0].Name) + assert.Equal(t, "Bearer token123", result[0].Http.Headers[0].Value) + }) + + t.Run("mixed servers filtered correctly", func(t *testing.T) { + tmpDir := t.TempDir() + mcpFile := filepath.Join(tmpDir, "mcp.json") + mcpContent := `{ + "mcpServers": { + "stdio-server": { + "command": "/usr/bin/stdio-mcp", + "args": [] + }, + "http-server": { + "type": "http", + "url": "https://example.com/mcp", + "headers": {} + } + } + }` + err := os.WriteFile(mcpFile, []byte(mcpContent), 0o644) + require.NoError(t, err) + + // With HTTP capability disabled, only stdio should be included + initResp := &acp.InitializeResponse{ + AgentCapabilities: acp.AgentCapabilities{ + McpCapabilities: acp.McpCapabilities{ + Http: false, + Sse: false, + }, + }, + } + result, err := getSupportedMCPConfig(mcpFile, logger, initResp) + require.NoError(t, err) + assert.Len(t, result, 1) + assert.NotNil(t, result[0].Stdio) + assert.Equal(t, "stdio-server", result[0].Stdio.Name) + + // With HTTP capability enabled, both should be included + initResp.AgentCapabilities.McpCapabilities.Http = true + result, err = getSupportedMCPConfig(mcpFile, logger, initResp) + require.NoError(t, err) + assert.Len(t, result, 2) + }) + + t.Run("empty mcpServers object returns empty slice", func(t *testing.T) { + tmpDir := t.TempDir() + mcpFile := filepath.Join(tmpDir, "mcp.json") + mcpContent := `{"mcpServers": {}}` + err := os.WriteFile(mcpFile, []byte(mcpContent), 0o644) + require.NoError(t, err) + + initResp := &acp.InitializeResponse{} + result, err := getSupportedMCPConfig(mcpFile, logger, initResp) + require.NoError(t, err) + assert.Empty(t, result) + }) + + t.Run("server without command or url is skipped", func(t *testing.T) { + tmpDir := t.TempDir() + mcpFile := filepath.Join(tmpDir, "mcp.json") + mcpContent := `{ + "mcpServers": { + "invalid-server": { + "args": ["--foo"] + } + } + }` + err := os.WriteFile(mcpFile, []byte(mcpContent), 0o644) + require.NoError(t, err) + + initResp := &acp.InitializeResponse{} + result, err := getSupportedMCPConfig(mcpFile, logger, initResp) + require.NoError(t, err) + // Invalid servers are skipped with a warning, not an error + assert.Empty(t, result) + }) + + t.Run("http server inferred from url field", func(t *testing.T) { + tmpDir := t.TempDir() + mcpFile := filepath.Join(tmpDir, "mcp.json") + // No explicit type, but has url - should be inferred as http + mcpContent := `{ + "mcpServers": { + "inferred-http": { + "url": "https://example.com/mcp" + } + } + }` + err := os.WriteFile(mcpFile, []byte(mcpContent), 0o644) + require.NoError(t, err) + + initResp := &acp.InitializeResponse{ + AgentCapabilities: acp.AgentCapabilities{ + McpCapabilities: acp.McpCapabilities{ + Http: true, + }, + }, + } + result, err := getSupportedMCPConfig(mcpFile, logger, initResp) + require.NoError(t, err) + assert.Len(t, result, 1) + assert.NotNil(t, result[0].Http) + assert.Equal(t, "inferred-http", result[0].Http.Name) + }) +} + +func TestConvertAgentapiMcpToAcp(t *testing.T) { + t.Run("converts stdio server correctly", func(t *testing.T) { + server := AgentapiMcpServer{ + Type: "stdio", + Command: "/usr/bin/mcp-server", + Args: []string{"--arg1", "--arg2"}, + Env: map[string]string{ + "KEY1": "value1", + "KEY2": "value2", + }, + } + + result, err := server.convertAgentapiMcpToAcp("my-server") + require.NoError(t, err) + require.NotNil(t, result.Stdio) + assert.Equal(t, "my-server", result.Stdio.Name) + assert.Equal(t, "/usr/bin/mcp-server", result.Stdio.Command) + assert.Equal(t, []string{"--arg1", "--arg2"}, result.Stdio.Args) + assert.Len(t, result.Stdio.Env, 2) + }) + + t.Run("converts http server correctly", func(t *testing.T) { + server := AgentapiMcpServer{ + Type: "http", + URL: "https://api.example.com/mcp", + Headers: map[string]string{ + "Authorization": "Bearer token", + "X-Custom": "value", + }, + } + + result, err := server.convertAgentapiMcpToAcp("api-server") + require.NoError(t, err) + require.NotNil(t, result.Http) + assert.Equal(t, "api-server", result.Http.Name) + assert.Equal(t, "https://api.example.com/mcp", result.Http.Url) + assert.Len(t, result.Http.Headers, 2) + }) + + t.Run("returns error for stdio without command", func(t *testing.T) { + server := AgentapiMcpServer{ + Type: "stdio", + Args: []string{"--arg"}, + } + + _, err := server.convertAgentapiMcpToAcp("bad-server") + require.Error(t, err) + assert.Contains(t, err.Error(), "missing command") + }) + + t.Run("returns error for http without url", func(t *testing.T) { + server := AgentapiMcpServer{ + Type: "http", + } + + _, err := server.convertAgentapiMcpToAcp("bad-server") + require.Error(t, err) + assert.Contains(t, err.Error(), "missing url") + }) + + t.Run("returns error for unsupported type", func(t *testing.T) { + server := AgentapiMcpServer{ + Type: "websocket", + } + + _, err := server.convertAgentapiMcpToAcp("bad-server") + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported server type") + }) +}