From b109c84e4e13e12131affc0e3a10a3e10a074c4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Fri, 29 May 2026 12:58:51 +0100 Subject: [PATCH] fix(backend): pass HTTP MCP servers to ACP agents via AssumeMcpHttp (#1152) * fix(backend): pass HTTP MCP servers to ACP agents via AssumeMcpHttp * test(backend): cover AssumeMcpHttp override path with effectiveMcpCapabilities helper --- apps/backend/internal/agent/agents/agent.go | 1 + apps/backend/internal/agent/agents/auggie.go | 1 + .../agent/runtime/agentctl/control.go | 1 + .../agent/runtime/lifecycle/container.go | 3 ++ .../runtime/lifecycle/executor_docker.go | 3 ++ .../runtime/lifecycle/executor_docker_test.go | 6 ++- .../runtime/lifecycle/executor_standalone.go | 3 ++ .../agentctl/server/adapter/adapter.go | 4 ++ .../adapter/transport/acp/adapter_session.go | 28 ++++++++++---- .../server/adapter/transport/acp/mcp_test.go | 37 +++++++++++++++++++ .../server/adapter/transport/shared/config.go | 3 ++ .../internal/agentctl/server/config/config.go | 7 ++++ .../agentctl/server/instance/instance.go | 3 ++ .../agentctl/server/instance/manager.go | 1 + .../agentctl/server/process/manager.go | 1 + 15 files changed, 93 insertions(+), 9 deletions(-) diff --git a/apps/backend/internal/agent/agents/agent.go b/apps/backend/internal/agent/agents/agent.go index 9864ab70ce..6c6d9b710c 100644 --- a/apps/backend/internal/agent/agents/agent.go +++ b/apps/backend/internal/agent/agents/agent.go @@ -180,6 +180,7 @@ type RuntimeConfig struct { ModelFlag Param // e.g. NewParam("--model", "{model}") WorkspaceFlag string // e.g. "--workspace-root" AssumeMcpSse bool // Override: assume agent supports SSE MCP servers even if not advertised + AssumeMcpHttp bool // Override: assume agent supports HTTP MCP servers even if not advertised ProjectSkillDir string // CWD-relative path for project-level skills (e.g. ".claude/skills") UserSkillDir string // home-relative path for user-level skills (e.g. ".claude/skills") } diff --git a/apps/backend/internal/agent/agents/auggie.go b/apps/backend/internal/agent/agents/auggie.go index 95d2cc57e6..b657bfcd13 100644 --- a/apps/backend/internal/agent/agents/auggie.go +++ b/apps/backend/internal/agent/agents/auggie.go @@ -101,6 +101,7 @@ func (a *Auggie) Runtime() *RuntimeConfig { Protocol: agent.ProtocolACP, WorkspaceFlag: "--workspace-root", AssumeMcpSse: true, + AssumeMcpHttp: true, ProjectSkillDir: ".agents/skills", SessionConfig: SessionConfig{ NativeSessionResume: true, diff --git a/apps/backend/internal/agent/runtime/agentctl/control.go b/apps/backend/internal/agent/runtime/agentctl/control.go index b0f7ef81f6..6bd11cb69b 100644 --- a/apps/backend/internal/agent/runtime/agentctl/control.go +++ b/apps/backend/internal/agent/runtime/agentctl/control.go @@ -52,6 +52,7 @@ type CreateInstanceRequest struct { TaskID string `json:"task_id,omitempty"` // Task ID for MCP plan tool calls (server-side injection) DisableAskQuestion bool `json:"disable_ask_question,omitempty"` // Disable ask_user_question MCP tool (TUI agents) AssumeMcpSse bool `json:"assume_mcp_sse,omitempty"` // Assume agent supports SSE MCP servers + AssumeMcpHttp bool `json:"assume_mcp_http,omitempty"` // Assume agent supports HTTP MCP servers McpMode string `json:"mcp_mode,omitempty"` // MCP tool mode: "task" (default) or "config" } diff --git a/apps/backend/internal/agent/runtime/lifecycle/container.go b/apps/backend/internal/agent/runtime/lifecycle/container.go index 5727ca4166..4f2e26bc69 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/container.go +++ b/apps/backend/internal/agent/runtime/lifecycle/container.go @@ -202,9 +202,11 @@ func (cm *ContainerManager) createInstanceAndClient( } disableAskQuestion := agents.IsPassthroughOnly(config.AgentConfig) assumeMcpSse := false + assumeMcpHttp := false if config.AgentConfig != nil { if rt := config.AgentConfig.Runtime(); rt != nil { assumeMcpSse = rt.AssumeMcpSse + assumeMcpHttp = rt.AssumeMcpHttp } } @@ -219,6 +221,7 @@ func (cm *ContainerManager) createInstanceAndClient( SessionID: config.SessionID, DisableAskQuestion: disableAskQuestion, AssumeMcpSse: assumeMcpSse, + AssumeMcpHttp: assumeMcpHttp, McpMode: config.McpMode, } diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go b/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go index 098fedb1f9..b7d07b6df0 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go @@ -443,11 +443,13 @@ func buildReconnectCreateInstanceRequest(req *ExecutorCreateRequest, instanceID agentType := "" disableAskQuestion := false assumeMcpSse := false + assumeMcpHttp := false if req.AgentConfig != nil { agentType = req.AgentConfig.ID() disableAskQuestion = agents.IsPassthroughOnly(req.AgentConfig) if rt := req.AgentConfig.Runtime(); rt != nil { assumeMcpSse = rt.AssumeMcpSse + assumeMcpHttp = rt.AssumeMcpHttp } } return &agentctl.CreateInstanceRequest{ @@ -461,6 +463,7 @@ func buildReconnectCreateInstanceRequest(req *ExecutorCreateRequest, instanceID TaskID: req.TaskID, DisableAskQuestion: disableAskQuestion, AssumeMcpSse: assumeMcpSse, + AssumeMcpHttp: assumeMcpHttp, McpMode: req.McpMode, } } diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_docker_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_docker_test.go index 69dec51ff4..ba0a3835c5 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_docker_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_docker_test.go @@ -515,7 +515,8 @@ func TestBuildReconnectCreateInstanceRequest(t *testing.T) { id: "codex", enabled: true, runtimeConfig: &agents.RuntimeConfig{ - AssumeMcpSse: true, + AssumeMcpSse: true, + AssumeMcpHttp: true, }, }, Env: map[string]string{ @@ -547,6 +548,9 @@ func TestBuildReconnectCreateInstanceRequest(t *testing.T) { if !got.AssumeMcpSse { t.Fatal("expected AssumeMcpSse to be propagated") } + if !got.AssumeMcpHttp { + t.Fatal("expected AssumeMcpHttp to be propagated") + } if got.McpMode != "config" { t.Fatalf("McpMode = %q, want config", got.McpMode) } diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go b/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go index e0b05af78b..22bd300f24 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go @@ -96,9 +96,11 @@ func (r *StandaloneExecutor) CreateInstance(ctx context.Context, req *ExecutorCr } disableAskQuestion := agents.IsPassthroughOnly(req.AgentConfig) assumeMcpSse := false + assumeMcpHttp := false if req.AgentConfig != nil { if rt := req.AgentConfig.Runtime(); rt != nil { assumeMcpSse = rt.AssumeMcpSse + assumeMcpHttp = rt.AssumeMcpHttp } } @@ -115,6 +117,7 @@ func (r *StandaloneExecutor) CreateInstance(ctx context.Context, req *ExecutorCr TaskID: req.TaskID, DisableAskQuestion: disableAskQuestion, AssumeMcpSse: assumeMcpSse, + AssumeMcpHttp: assumeMcpHttp, McpMode: req.McpMode, } diff --git a/apps/backend/internal/agentctl/server/adapter/adapter.go b/apps/backend/internal/agentctl/server/adapter/adapter.go index e2cfa8a86e..e582769a7c 100644 --- a/apps/backend/internal/agentctl/server/adapter/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/adapter.go @@ -267,6 +267,9 @@ type Config struct { // AssumeMcpSse overrides MCP capability filtering to assume SSE support. AssumeMcpSse bool + + // AssumeMcpHttp overrides MCP capability filtering to assume HTTP support. + AssumeMcpHttp bool } // ToSharedConfig converts this Config to the shared.Config used by transport adapters. @@ -296,5 +299,6 @@ func (c *Config) ToSharedConfig() *shared.Config { Headers: c.Headers, Extra: c.Extra, AssumeMcpSse: c.AssumeMcpSse, + AssumeMcpHttp: c.AssumeMcpHttp, } } diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session.go index 0cf9072908..92dc4225ac 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session.go @@ -36,10 +36,7 @@ func (a *Adapter) NewSession(ctx context.Context, mcpServers []types.McpServer) ctx, span := shared.TraceProtocolRequest(ctx, shared.ProtocolACP, a.agentID, "session.new") defer span.End() - caps := a.capabilities.McpCapabilities - if a.cfg.AssumeMcpSse { - caps.Sse = true - } + caps := effectiveMcpCapabilities(a.capabilities.McpCapabilities, a.cfg) filteredServers := filterMcpServersByCapabilities(mcpServers, caps, a.logger) resp, err := conn.NewSession(ctx, acp.NewSessionRequest{ Cwd: a.cfg.WorkDir, @@ -90,6 +87,24 @@ func (a *Adapter) NewSession(ctx context.Context, mcpServers []types.McpServer) return sessionID, nil } +// effectiveMcpCapabilities applies the adapter's AssumeMcpSse/AssumeMcpHttp +// config overrides on top of the capabilities advertised by the agent during +// the ACP handshake. Some agents (e.g. Auggie) support SSE/HTTP MCP transports +// but don't advertise the corresponding capability, which would otherwise cause +// filterMcpServersByCapabilities to drop user-configured remote MCP servers. +func effectiveMcpCapabilities(caps acp.McpCapabilities, cfg *shared.Config) acp.McpCapabilities { + if cfg == nil { + return caps + } + if cfg.AssumeMcpSse { + caps.Sse = true + } + if cfg.AssumeMcpHttp { + caps.Http = true + } + return caps +} + // filterMcpServersByCapabilities removes MCP servers that the agent doesn't support. // Stdio servers are always allowed; SSE/HTTP servers require the corresponding capability. // If multiple servers share the same name (e.g., dual SSE+HTTP injection), only the first @@ -225,10 +240,7 @@ func (a *Adapter) LoadSession(ctx context.Context, sessionID string, mcpServers defer span.End() // Filter MCP servers by agent capabilities (same logic as NewSession). - caps := a.capabilities.McpCapabilities - if a.cfg.AssumeMcpSse { - caps.Sse = true - } + caps := effectiveMcpCapabilities(a.capabilities.McpCapabilities, a.cfg) filteredServers := filterMcpServersByCapabilities(mcpServers, caps, a.logger) // Suppress history replay notifications during load. diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/mcp_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/mcp_test.go index f1c07b34f7..f286aecb73 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/mcp_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/mcp_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/coder/acp-go-sdk" + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/common/logger" ) @@ -390,3 +391,39 @@ func TestFilterMcpServersByCapabilities_DeduplicatesSameName(t *testing.T) { func newTestLoggerForMcp() *logger.Logger { return newTestAdapter().logger } + +// --- effectiveMcpCapabilities --- + +func TestEffectiveMcpCapabilities_AssumeHTTPAndSSE(t *testing.T) { + // Agent advertises neither SSE nor HTTP. + base := acp.McpCapabilities{Sse: false, Http: false} + + // Nil config leaves capabilities untouched. + if got := effectiveMcpCapabilities(base, nil); got.Sse || got.Http { + t.Errorf("nil cfg should not alter caps, got %+v", got) + } + + // AssumeMcpHttp forces Http on (and an http server then survives the filter). + caps := effectiveMcpCapabilities(base, &shared.Config{AssumeMcpHttp: true}) + if !caps.Http { + t.Fatal("AssumeMcpHttp should force caps.Http = true") + } + if caps.Sse { + t.Error("AssumeMcpHttp should not affect caps.Sse") + } + + log := newTestLoggerForMcp() + httpServer := []types.McpServer{{Name: "remote", Type: "http", URL: "https://mcp.example.com/mcp"}} + if got := filterMcpServersByCapabilities(httpServer, caps, log); len(got) != 1 { + t.Fatalf("http server should survive when AssumeMcpHttp set, got %d servers", len(got)) + } + + // AssumeMcpSse forces Sse on independently. + caps = effectiveMcpCapabilities(base, &shared.Config{AssumeMcpSse: true}) + if !caps.Sse { + t.Fatal("AssumeMcpSse should force caps.Sse = true") + } + if caps.Http { + t.Error("AssumeMcpSse should not affect caps.Http") + } +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/shared/config.go b/apps/backend/internal/agentctl/server/adapter/transport/shared/config.go index ed25eec8d0..4458618198 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/shared/config.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/shared/config.go @@ -49,6 +49,9 @@ type Config struct { // AssumeMcpSse overrides MCP capability filtering to assume SSE support. AssumeMcpSse bool + + // AssumeMcpHttp overrides MCP capability filtering to assume HTTP support. + AssumeMcpHttp bool } // GetPermissionTimeout returns the configured permission timeout or the default. diff --git a/apps/backend/internal/agentctl/server/config/config.go b/apps/backend/internal/agentctl/server/config/config.go index 356e8b452c..3e7e66882c 100644 --- a/apps/backend/internal/agentctl/server/config/config.go +++ b/apps/backend/internal/agentctl/server/config/config.go @@ -192,6 +192,9 @@ type InstanceConfig struct { // AssumeMcpSse overrides MCP capability filtering to assume the agent supports SSE. AssumeMcpSse bool + // AssumeMcpHttp overrides MCP capability filtering to assume the agent supports HTTP. + AssumeMcpHttp bool + // McpMode controls which MCP tools are registered for this instance. // "task" (default) registers kanban/plan tools; "config" registers config tools. McpMode string @@ -348,6 +351,9 @@ func applyOverrides(cfg *InstanceConfig, overrides *InstanceOverrides) { if overrides.AssumeMcpSse { cfg.AssumeMcpSse = true } + if overrides.AssumeMcpHttp { + cfg.AssumeMcpHttp = true + } if overrides.McpMode != "" { cfg.McpMode = overrides.McpMode } @@ -368,6 +374,7 @@ type InstanceOverrides struct { TaskID string DisableAskQuestion bool AssumeMcpSse bool + AssumeMcpHttp bool McpMode string } diff --git a/apps/backend/internal/agentctl/server/instance/instance.go b/apps/backend/internal/agentctl/server/instance/instance.go index b528b123e3..ef7dbf9082 100644 --- a/apps/backend/internal/agentctl/server/instance/instance.go +++ b/apps/backend/internal/agentctl/server/instance/instance.go @@ -95,6 +95,9 @@ type CreateRequest struct { // AssumeMcpSse overrides MCP capability filtering to assume SSE support. AssumeMcpSse bool `json:"assume_mcp_sse,omitempty"` + // AssumeMcpHttp overrides MCP capability filtering to assume HTTP support. + AssumeMcpHttp bool `json:"assume_mcp_http,omitempty"` + // McpMode controls which MCP tools are registered: "task" (default) or "config". McpMode string `json:"mcp_mode,omitempty"` } diff --git a/apps/backend/internal/agentctl/server/instance/manager.go b/apps/backend/internal/agentctl/server/instance/manager.go index e94ee089f5..5d95b1dd63 100644 --- a/apps/backend/internal/agentctl/server/instance/manager.go +++ b/apps/backend/internal/agentctl/server/instance/manager.go @@ -93,6 +93,7 @@ func (m *Manager) CreateInstance(ctx context.Context, req *CreateRequest) (*Crea TaskID: req.TaskID, DisableAskQuestion: req.DisableAskQuestion, AssumeMcpSse: req.AssumeMcpSse, + AssumeMcpHttp: req.AssumeMcpHttp, McpMode: req.McpMode, } diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index d01932719d..584d99ebcc 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -634,6 +634,7 @@ func (m *Manager) buildAdapterConfig() error { McpServers: mcpServers, AgentID: m.cfg.AgentType, // From registry (e.g., "auggie", "amp", "claude-code") AssumeMcpSse: m.cfg.AssumeMcpSse, + AssumeMcpHttp: m.cfg.AssumeMcpHttp, } // Configure one-shot mode when a continue command is provided.