From f85ae95afef5c0e04bb0db907547db1c71a57b96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 31 Aug 2026 18:46:30 +0000 Subject: [PATCH] fix: preserve canonical MCP tool names for Auggie (#3196) --- apps/backend/cmd/agentctl/main.go | 7 +- apps/backend/internal/agent/agents/agent.go | 5 + apps/backend/internal/agent/agents/auggie.go | 13 +- .../agent/agents/new_acp_agents_test.go | 23 +++ .../agent/runtime/agentctl/control.go | 4 + .../agent/runtime/agentctl/control_test.go | 21 +++ .../agent/runtime/lifecycle/container.go | 39 +++-- .../runtime/lifecycle/executor_docker.go | 33 ++-- .../lifecycle/executor_sprites_operations.go | 33 ++-- .../lifecycle/executor_ssh_operations.go | 23 +-- .../runtime/lifecycle/executor_standalone.go | 35 ++-- .../lifecycle/provider_propagation_test.go | 26 +++ .../runtime/lifecycle/ssh_test_agents_test.go | 12 +- .../internal/agentctl/server/config/config.go | 58 ++++--- .../agentctl/server/config/config_test.go | 11 ++ .../agentctl/server/instance/instance.go | 4 + .../agentctl/server/instance/manager.go | 49 +++--- .../instance/manager_diagnostics_test.go | 28 +++ apps/backend/internal/mcp/server/server.go | 110 +++++++++--- .../mcp/server/tool_name_presentation_test.go | 152 ++++++++++++++++ .../2026-08-31-agent-aware-mcp-tool-names.md | 79 +++++++++ docs/decisions/INDEX.md | 1 + docs/plans/auggie-mcp-tool-names/plan.md | 128 ++++++++++++++ .../task-01-preserve-canonical-tool-names.md | 132 ++++++++++++++ docs/specs/tasks/README.md | 4 +- .../requirements/mcp-tool-name-stability.md | 75 ++++++++ .../system-design/mcp-tool-name-stability.md | 162 ++++++++++++++++++ 27 files changed, 1107 insertions(+), 160 deletions(-) create mode 100644 apps/backend/internal/mcp/server/tool_name_presentation_test.go create mode 100644 docs/decisions/2026-08-31-agent-aware-mcp-tool-names.md create mode 100644 docs/plans/auggie-mcp-tool-names/plan.md create mode 100644 docs/plans/auggie-mcp-tool-names/task-01-preserve-canonical-tool-names.md create mode 100644 docs/specs/tasks/requirements/mcp-tool-name-stability.md create mode 100644 docs/specs/tasks/system-design/mcp-tool-name-stability.md diff --git a/apps/backend/cmd/agentctl/main.go b/apps/backend/cmd/agentctl/main.go index 09e800f45a..94116df5d5 100644 --- a/apps/backend/cmd/agentctl/main.go +++ b/apps/backend/cmd/agentctl/main.go @@ -30,6 +30,7 @@ import ( "github.com/kandev/kandev/internal/agentctl/tracing" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/githubauth" + mcpprofile "github.com/kandev/kandev/internal/mcp/profile" mcpserver "github.com/kandev/kandev/internal/mcp/server" "github.com/kandev/kandev/internal/profiles" "github.com/kandev/kandev/pkg/agent" @@ -210,10 +211,12 @@ func run(cfg *config.Config, log *logger.Logger) { // Create MCP server using the channel-based backend client var mcpSrv *mcpserver.Server + mcpNamePresentationOption := mcpserver.WithMCPToolNamespacingByServer(instCfg.NamespacesMCPToolsByServer) if instCfg.McpProfile != nil { - mcpSrv = mcpserver.NewWithProfile(mcpBackendClient, instCfg.SessionID, instCfg.TaskID, instCfg.Port, instLog, cfg.McpLogFile, instCfg.DisableAskQuestion, *instCfg.McpProfile) + mcpSrv = mcpserver.NewWithProfile(mcpBackendClient, instCfg.SessionID, instCfg.TaskID, instCfg.Port, instLog, cfg.McpLogFile, instCfg.DisableAskQuestion, *instCfg.McpProfile, mcpNamePresentationOption) } else { - mcpSrv = mcpserver.New(mcpBackendClient, instCfg.SessionID, instCfg.TaskID, instCfg.Port, instLog, cfg.McpLogFile, instCfg.DisableAskQuestion, instCfg.McpMode, instCfg.McpProviders) + legacyProfile := mcpprofile.Legacy(instCfg.McpMode, instCfg.DisableAskQuestion, instCfg.McpProviders) + mcpSrv = mcpserver.NewWithProfile(mcpBackendClient, instCfg.SessionID, instCfg.TaskID, instCfg.Port, instLog, cfg.McpLogFile, instCfg.DisableAskQuestion, legacyProfile, mcpNamePresentationOption) } mcpSrv.SetAttachmentReporter(procMgr.PublishMCPAttachment) instLog.Info("MCP server enabled (channel-based)", diff --git a/apps/backend/internal/agent/agents/agent.go b/apps/backend/internal/agent/agents/agent.go index 0cb64a9f70..526673ef4b 100644 --- a/apps/backend/internal/agent/agents/agent.go +++ b/apps/backend/internal/agent/agents/agent.go @@ -275,6 +275,11 @@ type RuntimeConfig struct { // final child env after adapter merge; the inference executor strips // them from the one-shot probe/inference subprocess env. StripEnv []string + // NamespacesMCPToolsByServer is true for clients that add the MCP server + // name to every tool before presenting it to the model. The per-instance + // Kandev MCP server removes that presentation suffix before the client adds + // it back, so the model sees the canonical tool name. + NamespacesMCPToolsByServer bool } // MountTemplate defines a mount with template variables. diff --git a/apps/backend/internal/agent/agents/auggie.go b/apps/backend/internal/agent/agents/auggie.go index c2a10c5762..a6495e9d38 100644 --- a/apps/backend/internal/agent/agents/auggie.go +++ b/apps/backend/internal/agent/agents/auggie.go @@ -97,12 +97,13 @@ func (a *Auggie) Runtime() *RuntimeConfig { Mounts: []MountTemplate{ {Source: "{workspace}", Target: "/workspace"}, }, - ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour}, - Protocol: agent.ProtocolACP, - WorkspaceFlag: "--workspace-root", - AssumeMcpSse: true, - AssumeMcpHttp: true, - ProjectSkillDir: ".agents/skills", + ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour}, + Protocol: agent.ProtocolACP, + WorkspaceFlag: "--workspace-root", + AssumeMcpSse: true, + AssumeMcpHttp: true, + NamespacesMCPToolsByServer: true, + ProjectSkillDir: ".agents/skills", SessionConfig: SessionConfig{ NativeSessionResume: true, CanRecover: &canRecover, diff --git a/apps/backend/internal/agent/agents/new_acp_agents_test.go b/apps/backend/internal/agent/agents/new_acp_agents_test.go index 89bf59817b..89484b01aa 100644 --- a/apps/backend/internal/agent/agents/new_acp_agents_test.go +++ b/apps/backend/internal/agent/agents/new_acp_agents_test.go @@ -227,6 +227,29 @@ func TestNewACPAgents_AllCommandSurfaces(t *testing.T) { } } +// TestAuggieRuntime_DeclaresServerNamespacedMCPTools covers +// AC-TASKS-MCP-TOOL-NAMES-001.1 and .2. +func TestAuggieRuntime_DeclaresServerNamespacedMCPTools(t *testing.T) { + tests := []struct { + name string + new func() Agent + want bool + }{ + {name: "auggie", new: func() Agent { return NewAuggie() }, want: true}, + {name: "cursor", new: func() Agent { return NewCursorACP() }, want: false}, + {name: "codex", new: func() Agent { return NewCodexACP() }, want: false}, + {name: "claude", new: func() Agent { return NewClaudeACP() }, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.new().Runtime().NamespacesMCPToolsByServer + if got != tt.want { + t.Errorf("NamespacesMCPToolsByServer = %t, want %t", got, tt.want) + } + }) + } +} + // TestNewACPAgents_SessionDirTemplate pins the session root each agent // declares. The lifecycle manager turns SessionDirTemplate into the // bind-mount source under /agent-sessions//, so an diff --git a/apps/backend/internal/agent/runtime/agentctl/control.go b/apps/backend/internal/agent/runtime/agentctl/control.go index e988d6575e..72dc4b262a 100644 --- a/apps/backend/internal/agent/runtime/agentctl/control.go +++ b/apps/backend/internal/agent/runtime/agentctl/control.go @@ -71,6 +71,10 @@ type CreateInstanceRequest struct { // RuntimeConfig.StripEnv by the lifecycle executors. StripEnv []string `json:"strip_env,omitempty"` + // NamespacesMCPToolsByServer tells the per-instance MCP server to adapt + // built-in tool names for an agent that appends the server name itself. + NamespacesMCPToolsByServer bool `json:"namespaces_mcp_tools_by_server,omitempty"` + // BaseBranches maps RepositoryName → base branch ref for the task's // per-repo diff stats. The empty key "" applies to the root / // single-repo tracker. Empty map disables the override and falls back diff --git a/apps/backend/internal/agent/runtime/agentctl/control_test.go b/apps/backend/internal/agent/runtime/agentctl/control_test.go index fba38b1a24..416cff6a04 100644 --- a/apps/backend/internal/agent/runtime/agentctl/control_test.go +++ b/apps/backend/internal/agent/runtime/agentctl/control_test.go @@ -380,6 +380,27 @@ func TestCreateInstance_MarshalsFullRequestAndDecodesIDAndPort(t *testing.T) { } } +// TestCreateInstance_MarshalsMCPToolNamePresentationCapability covers the +// wire portion of AC-TASKS-MCP-TOOL-NAMES-001.5. +func TestCreateInstance_MarshalsMCPToolNamePresentationCapability(t *testing.T) { + srv, got := captureServer(t, jsonResponder(http.StatusCreated, `{"id":"inst-1","port":41234}`)) + req := &CreateInstanceRequest{ + WorkspacePath: "/workspace/task-1", + NamespacesMCPToolsByServer: true, + } + + if _, err := newTestControlClient(t, srv).CreateInstance(context.Background(), req); err != nil { + t.Fatalf("CreateInstance: %v", err) + } + var body map[string]interface{} + if err := json.Unmarshal(got.Body, &body); err != nil { + t.Fatalf("decode request: %v", err) + } + if got, ok := body["namespaces_mcp_tools_by_server"].(bool); !ok || !got { + t.Fatalf("serialized capability = %#v, want true", body["namespaces_mcp_tools_by_server"]) + } +} + // 200 is accepted alongside 201 — agentctl returns 200 when it reuses an // existing instance for the same ID. func TestCreateInstance_AcceptsBoth200And201(t *testing.T) { diff --git a/apps/backend/internal/agent/runtime/lifecycle/container.go b/apps/backend/internal/agent/runtime/lifecycle/container.go index 809e507518..f1fc0b962e 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/container.go +++ b/apps/backend/internal/agent/runtime/lifecycle/container.go @@ -87,6 +87,14 @@ func autoApprovePermissionsOverride(enabled bool, override *bool) *bool { return nil } +func namespacesMCPToolsByServerFromAgent(agent agents.Agent) bool { + if agent == nil { + return false + } + rt := agent.Runtime() + return rt != nil && rt.NamespacesMCPToolsByServer +} + func buildContainerCreateInstanceRequest( config ContainerConfig, agentType string, @@ -103,21 +111,22 @@ func buildContainerCreateInstanceRequest( config.AutoApprovePermissions, config.AutoApprovePermissionsOverride, ), - AutoStart: false, - McpServers: config.McpServers, - SessionID: config.SessionID, - DisableAskQuestion: disableAskQuestion, - AssumeMcpSse: assumeMcpSse, - AssumeMcpHttp: assumeMcpHttp, - McpMode: config.McpMode, - McpProviders: config.McpProviders, - McpProfile: config.McpProfile, - RequiresProcessKill: requiresProcessKill, - StripEnv: stripEnv, - BaseBranches: config.BaseBranches, - RemoteContributions: config.RemoteContributions, - ContributionDestinations: config.ContributionDestinations, - ComparisonTargets: config.ComparisonTargets, + AutoStart: false, + McpServers: config.McpServers, + SessionID: config.SessionID, + DisableAskQuestion: disableAskQuestion, + AssumeMcpSse: assumeMcpSse, + AssumeMcpHttp: assumeMcpHttp, + McpMode: config.McpMode, + McpProviders: config.McpProviders, + McpProfile: config.McpProfile, + NamespacesMCPToolsByServer: namespacesMCPToolsByServerFromAgent(config.AgentConfig), + RequiresProcessKill: requiresProcessKill, + StripEnv: stripEnv, + BaseBranches: config.BaseBranches, + RemoteContributions: config.RemoteContributions, + ContributionDestinations: config.ContributionDestinations, + ComparisonTargets: config.ComparisonTargets, } } diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go b/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go index f3c94196e1..f9ff3832ce 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go @@ -586,22 +586,23 @@ func buildReconnectCreateInstanceRequest(req *ExecutorCreateRequest, instanceID req.AutoApprovePermissions, req.AutoApprovePermissionsOverride, ), - AutoStart: false, - McpServers: req.McpServers, - McpProviders: req.McpProviders, - McpProfile: req.McpProfile, - SessionID: req.SessionID, - TaskID: req.TaskID, - DisableAskQuestion: disableAskQuestion, - AssumeMcpSse: assumeMcpSse, - AssumeMcpHttp: assumeMcpHttp, - McpMode: req.McpMode, - RequiresProcessKill: requiresProcessKill, - StripEnv: stripEnv, - BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches), - RemoteContributions: req.RemoteContributions, - ContributionDestinations: req.ContributionDestinations, - ComparisonTargets: req.ComparisonTargets, + AutoStart: false, + McpServers: req.McpServers, + McpProviders: req.McpProviders, + McpProfile: req.McpProfile, + NamespacesMCPToolsByServer: namespacesMCPToolsByServerFromReq(req), + SessionID: req.SessionID, + TaskID: req.TaskID, + DisableAskQuestion: disableAskQuestion, + AssumeMcpSse: assumeMcpSse, + AssumeMcpHttp: assumeMcpHttp, + McpMode: req.McpMode, + RequiresProcessKill: requiresProcessKill, + StripEnv: stripEnv, + BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches), + RemoteContributions: req.RemoteContributions, + ContributionDestinations: req.ContributionDestinations, + ComparisonTargets: req.ComparisonTargets, } } diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go index b38cc01c0f..d2ae4628a4 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go @@ -449,17 +449,18 @@ func spriteCreateInstanceRequest(req *ExecutorCreateRequest) agentctl.CreateInst req.AutoApprovePermissions, req.AutoApprovePermissionsOverride, ), - McpServers: req.McpServers, - McpMode: req.McpMode, - McpProviders: req.McpProviders, - McpProfile: req.McpProfile, - RequiresProcessKill: requiresProcessKillFromReq(req), - StripEnv: stripEnvFromReq(req), - BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches), - RemoteContributions: req.RemoteContributions, - ContributionDestinations: req.ContributionDestinations, - ComparisonTargets: req.ComparisonTargets, - Env: cloneStringMap(req.Env), + McpServers: req.McpServers, + McpMode: req.McpMode, + McpProviders: req.McpProviders, + McpProfile: req.McpProfile, + NamespacesMCPToolsByServer: namespacesMCPToolsByServerFromReq(req), + RequiresProcessKill: requiresProcessKillFromReq(req), + StripEnv: stripEnvFromReq(req), + BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches), + RemoteContributions: req.RemoteContributions, + ContributionDestinations: req.ContributionDestinations, + ComparisonTargets: req.ComparisonTargets, + Env: cloneStringMap(req.Env), } } @@ -596,6 +597,16 @@ func requiresProcessKillFromReq(req *ExecutorCreateRequest) bool { return rt.RequiresProcessKill } +// namespacesMCPToolsByServerFromReq returns the agent's MCP tool presentation +// setting from its RuntimeConfig (false when unset). +func namespacesMCPToolsByServerFromReq(req *ExecutorCreateRequest) bool { + if req == nil || req.AgentConfig == nil { + return false + } + rt := req.AgentConfig.Runtime() + return rt != nil && rt.NamespacesMCPToolsByServer +} + // stripEnvFromReq returns the agent's StripEnv list from its RuntimeConfig // (nil when unset). Used by remote executors (SSH, Sprites) that serialize // the CreateInstanceRequest over the wire. diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go index 8f871abc5d..82f28531db 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go @@ -648,17 +648,18 @@ func buildSSHCreateInstanceRequest( req.AutoApprovePermissions, req.AutoApprovePermissionsOverride, ), - McpServers: req.McpServers, - McpMode: req.McpMode, - McpProviders: req.McpProviders, - McpProfile: req.McpProfile, - RequiresProcessKill: requiresProcessKillFromReq(req), - StripEnv: stripEnvFromReq(req), - BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches), - RemoteContributions: req.RemoteContributions, - ContributionDestinations: req.ContributionDestinations, - ComparisonTargets: req.ComparisonTargets, - Env: sshRemoteContributionEnv(req, agentctlBin), + McpServers: req.McpServers, + McpMode: req.McpMode, + McpProviders: req.McpProviders, + McpProfile: req.McpProfile, + NamespacesMCPToolsByServer: namespacesMCPToolsByServerFromReq(req), + RequiresProcessKill: requiresProcessKillFromReq(req), + StripEnv: stripEnvFromReq(req), + BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches), + RemoteContributions: req.RemoteContributions, + ContributionDestinations: req.ContributionDestinations, + ComparisonTargets: req.ComparisonTargets, + Env: sshRemoteContributionEnv(req, agentctlBin), } } diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go b/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go index a1d4d732f3..fee46b5c06 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go @@ -100,23 +100,24 @@ func buildStandaloneCreateInstanceRequest( req.AutoApprovePermissions, req.AutoApprovePermissionsOverride, ), - AutoStart: false, - McpServers: req.McpServers, - SessionID: req.SessionID, - TaskID: req.TaskID, - DisableAskQuestion: disableAskQuestion, - AssumeMcpSse: assumeMcpSse, - AssumeMcpHttp: assumeMcpHttp, - McpMode: req.McpMode, - McpProviders: req.McpProviders, - McpProfile: req.McpProfile, - RequiresProcessKill: requiresProcessKill, - StripEnv: stripEnv, - BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches), - RemoteContributions: req.RemoteContributions, - ContributionDestinations: req.ContributionDestinations, - ComparisonTargets: req.ComparisonTargets, - WorkspaceSourceRoots: req.WorkspaceSourceRoots, + AutoStart: false, + McpServers: req.McpServers, + SessionID: req.SessionID, + TaskID: req.TaskID, + DisableAskQuestion: disableAskQuestion, + AssumeMcpSse: assumeMcpSse, + AssumeMcpHttp: assumeMcpHttp, + McpMode: req.McpMode, + McpProviders: req.McpProviders, + McpProfile: req.McpProfile, + NamespacesMCPToolsByServer: namespacesMCPToolsByServerFromReq(req), + RequiresProcessKill: requiresProcessKill, + StripEnv: stripEnv, + BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches), + RemoteContributions: req.RemoteContributions, + ContributionDestinations: req.ContributionDestinations, + ComparisonTargets: req.ComparisonTargets, + WorkspaceSourceRoots: req.WorkspaceSourceRoots, } } diff --git a/apps/backend/internal/agent/runtime/lifecycle/provider_propagation_test.go b/apps/backend/internal/agent/runtime/lifecycle/provider_propagation_test.go index f8aff830d1..d4a8311e3d 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/provider_propagation_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/provider_propagation_test.go @@ -5,6 +5,8 @@ import ( "reflect" "testing" + "github.com/kandev/kandev/internal/agent/agents" + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" "github.com/kandev/kandev/internal/task/models" ) @@ -43,6 +45,30 @@ func TestAgentctlProviderMappingsPreserveProviderCapabilities(t *testing.T) { } } +func TestAgentctlMCPToolNamePresentationCapabilityPropagatesThroughExecutors(t *testing.T) { + agent := &fakeRuntimeAgent{ + MockAgent: agents.NewMockAgent(), + id: "auggie", + namespacesMCPTools: true, + } + req := &ExecutorCreateRequest{AgentConfig: agent} + sprites := spriteCreateInstanceRequest(req) + ssh := buildSSHCreateInstanceRequest(req, "/workspace", "/remote/agentctl") + + requests := map[string]*agentctl.CreateInstanceRequest{ + "standalone": buildStandaloneCreateInstanceRequest(req, nil, "", false, false, false, false, nil), + "container": buildContainerCreateInstanceRequest(ContainerConfig{AgentConfig: agent}, "", false, false, false, false, nil), + "docker": buildReconnectCreateInstanceRequest(req, "previous-execution"), + "sprites": &sprites, + "ssh": &ssh, + } + for executor, request := range requests { + if !request.NamespacesMCPToolsByServer { + t.Errorf("%s request lost NamespacesMCPToolsByServer", executor) + } + } +} + func TestManagerLaunchCarriesMCPProvidersToExecutor(t *testing.T) { mgr, backend := newEnvironmentExecutionTestManager(t, nil) want := []string{"github", "gitlab"} diff --git a/apps/backend/internal/agent/runtime/lifecycle/ssh_test_agents_test.go b/apps/backend/internal/agent/runtime/lifecycle/ssh_test_agents_test.go index c0148518cb..a6e857f303 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/ssh_test_agents_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/ssh_test_agents_test.go @@ -35,17 +35,19 @@ func (a *fakeIdentityAgent) BuildCommand(opts agents.CommandOptions) agents.Comm // create-instance request reads. type fakeRuntimeAgent struct { *agents.MockAgent - id string - requiresKill bool - stripEnv []string + id string + requiresKill bool + stripEnv []string + namespacesMCPTools bool } func (a *fakeRuntimeAgent) ID() string { return a.id } func (a *fakeRuntimeAgent) Runtime() *agents.RuntimeConfig { return &agents.RuntimeConfig{ - RequiresProcessKill: a.requiresKill, - StripEnv: a.stripEnv, + RequiresProcessKill: a.requiresKill, + StripEnv: a.stripEnv, + NamespacesMCPToolsByServer: a.namespacesMCPTools, } } diff --git a/apps/backend/internal/agentctl/server/config/config.go b/apps/backend/internal/agentctl/server/config/config.go index 9dfbf9b8b1..cb55d6ecc5 100644 --- a/apps/backend/internal/agentctl/server/config/config.go +++ b/apps/backend/internal/agentctl/server/config/config.go @@ -253,6 +253,10 @@ type InstanceConfig struct { // McpProfile is the backend-owned typed MCP tool profile. McpProfile *mcpprofile.Context + // NamespacesMCPToolsByServer enables the per-instance MCP name adapter for + // clients that append the injected server name to every tool. + NamespacesMCPToolsByServer bool + // AuthToken is a shared secret for authenticating requests. // Inherited from the parent Config at instance creation time. AuthToken string @@ -524,6 +528,9 @@ func applyOverrides(cfg *InstanceConfig, overrides *InstanceOverrides) { profileContext := *overrides.McpProfile cfg.McpProfile = &profileContext } + if overrides.NamespacesMCPToolsByServer { + cfg.NamespacesMCPToolsByServer = true + } if overrides.RequiresProcessKill { cfg.RequiresProcessKill = true } @@ -566,31 +573,32 @@ func applyApprovalOverrides(cfg *InstanceConfig, overrides *InstanceOverrides) { // InstanceOverrides allows overriding default values when creating an instance type InstanceOverrides struct { - InstanceID string - Protocol agent.Protocol - AgentCommand string - WorkDir string - AutoStart *bool - Env []string - AutoApprovePermissions *bool - ApprovalPolicy string - AgentType string - McpServers []McpServerConfig - SessionID string - TaskID string - DisableAskQuestion bool - AssumeMcpSse bool - AssumeMcpHttp bool - McpMode string - McpProviders []string - McpProfile *mcpprofile.Context - RequiresProcessKill bool - StripEnv []string - BaseBranches map[string]string - ComparisonTargets map[string]models.ComparisonTarget - RemoteContributions map[string]models.RemoteContribution - ContributionDestinations map[string]models.ContributionDestination - WorkspaceSourceRoots []string + InstanceID string + Protocol agent.Protocol + AgentCommand string + WorkDir string + AutoStart *bool + Env []string + AutoApprovePermissions *bool + ApprovalPolicy string + AgentType string + McpServers []McpServerConfig + SessionID string + TaskID string + DisableAskQuestion bool + AssumeMcpSse bool + AssumeMcpHttp bool + McpMode string + McpProviders []string + McpProfile *mcpprofile.Context + NamespacesMCPToolsByServer bool + RequiresProcessKill bool + StripEnv []string + BaseBranches map[string]string + ComparisonTargets map[string]models.ComparisonTarget + RemoteContributions map[string]models.RemoteContribution + ContributionDestinations map[string]models.ContributionDestination + WorkspaceSourceRoots []string } func cloneComparisonTargets(values map[string]models.ComparisonTarget) map[string]models.ComparisonTarget { diff --git a/apps/backend/internal/agentctl/server/config/config_test.go b/apps/backend/internal/agentctl/server/config/config_test.go index 0404d29152..0edb77224f 100644 --- a/apps/backend/internal/agentctl/server/config/config_test.go +++ b/apps/backend/internal/agentctl/server/config/config_test.go @@ -52,6 +52,17 @@ func TestNewInstanceConfigNormalizesMcpProviders(t *testing.T) { } } +// TestNewInstanceConfig_PropagatesMCPToolNamePresentationCapability covers +// AC-TASKS-MCP-TOOL-NAMES-001.5 at the agentctl instance boundary. +func TestNewInstanceConfig_PropagatesMCPToolNamePresentationCapability(t *testing.T) { + cfg := (&Config{}).NewInstanceConfig(0, &InstanceOverrides{ + NamespacesMCPToolsByServer: true, + }) + if !cfg.NamespacesMCPToolsByServer { + t.Fatal("InstanceConfig did not retain NamespacesMCPToolsByServer") + } +} + func TestCollectAgentEnvKeepsGitHubCLIShimAheadOfProfilePath(t *testing.T) { t.Setenv("KANDEV_GITHUB_CREDENTIAL_BROKER_URL", "https://kandev.example/api/github/credentials/resolve") t.Setenv("KANDEV_GITHUB_CLI_SHIM_DIR", "/kandev/shims") diff --git a/apps/backend/internal/agentctl/server/instance/instance.go b/apps/backend/internal/agentctl/server/instance/instance.go index c9a65c4c6a..e7f34f8b29 100644 --- a/apps/backend/internal/agentctl/server/instance/instance.go +++ b/apps/backend/internal/agentctl/server/instance/instance.go @@ -169,6 +169,10 @@ type CreateRequest struct { McpProviders []string `json:"mcp_providers,omitempty"` McpProfile *mcpprofile.Context `json:"mcp_profile,omitempty"` + // NamespacesMCPToolsByServer enables the per-instance MCP name adapter for + // clients that append the injected server name to every tool. + NamespacesMCPToolsByServer bool `json:"namespaces_mcp_tools_by_server,omitempty"` + // RequiresProcessKill forces the agent's process group to be killed on // shutdown instead of relying on stdin close. Required for agents whose // runtime keeps child processes alive when stdin closes (notably diff --git a/apps/backend/internal/agentctl/server/instance/manager.go b/apps/backend/internal/agentctl/server/instance/manager.go index 25f6a9aee7..7a1c033b78 100644 --- a/apps/backend/internal/agentctl/server/instance/manager.go +++ b/apps/backend/internal/agentctl/server/instance/manager.go @@ -165,30 +165,31 @@ func (m *Manager) CreateInstance(ctx context.Context, req *CreateRequest) (*Crea zap.String("workspace_path", req.WorkspacePath)) overrides := &config.InstanceOverrides{ - InstanceID: id, - Protocol: agent.Protocol(req.Protocol), - AgentCommand: agentCmd, - WorkDir: req.WorkspacePath, - AutoStart: &autoStart, - Env: agentEnv, - AutoApprovePermissions: req.AutoApprovePermissions, - AgentType: req.AgentType, - McpServers: mcpServers, - SessionID: req.SessionID, - TaskID: req.TaskID, - DisableAskQuestion: req.DisableAskQuestion, - AssumeMcpSse: req.AssumeMcpSse, - AssumeMcpHttp: req.AssumeMcpHttp, - McpMode: req.McpMode, - McpProviders: req.McpProviders, - McpProfile: req.McpProfile, - RequiresProcessKill: req.RequiresProcessKill, - StripEnv: req.StripEnv, - BaseBranches: req.BaseBranches, - ComparisonTargets: req.ComparisonTargets, - RemoteContributions: req.RemoteContributions, - ContributionDestinations: req.ContributionDestinations, - WorkspaceSourceRoots: req.WorkspaceSourceRoots, + InstanceID: id, + Protocol: agent.Protocol(req.Protocol), + AgentCommand: agentCmd, + WorkDir: req.WorkspacePath, + AutoStart: &autoStart, + Env: agentEnv, + AutoApprovePermissions: req.AutoApprovePermissions, + AgentType: req.AgentType, + McpServers: mcpServers, + SessionID: req.SessionID, + TaskID: req.TaskID, + DisableAskQuestion: req.DisableAskQuestion, + AssumeMcpSse: req.AssumeMcpSse, + AssumeMcpHttp: req.AssumeMcpHttp, + McpMode: req.McpMode, + McpProviders: req.McpProviders, + McpProfile: req.McpProfile, + NamespacesMCPToolsByServer: req.NamespacesMCPToolsByServer, + RequiresProcessKill: req.RequiresProcessKill, + StripEnv: req.StripEnv, + BaseBranches: req.BaseBranches, + ComparisonTargets: req.ComparisonTargets, + RemoteContributions: req.RemoteContributions, + ContributionDestinations: req.ContributionDestinations, + WorkspaceSourceRoots: req.WorkspaceSourceRoots, } m.logger.Info("CreateInstance: applying overrides", diff --git a/apps/backend/internal/agentctl/server/instance/manager_diagnostics_test.go b/apps/backend/internal/agentctl/server/instance/manager_diagnostics_test.go index ff38b4110e..2202d434fd 100644 --- a/apps/backend/internal/agentctl/server/instance/manager_diagnostics_test.go +++ b/apps/backend/internal/agentctl/server/instance/manager_diagnostics_test.go @@ -46,3 +46,31 @@ func TestCreateInstance_RecordsCreateReadyMillis(t *testing.T) { t.Errorf("CreateReadyMillis = %d, want > 0 after a completed creation", millis) } } + +func TestCreateInstance_PropagatesMCPToolNamePresentationCapability(t *testing.T) { + log := newTestLogger(t) + mgr := NewManager(&config.Config{ + Ports: config.PortConfig{Base: 0, Max: 0}, + Defaults: config.InstanceDefaults{Protocol: agent.ProtocolACP}, + }, log) + t.Cleanup(func() { _ = mgr.Shutdown(context.Background()) }) + + var captured *config.InstanceConfig + mgr.SetServerFactory(func(cfg *config.InstanceConfig, procMgr *process.Manager, log *logger.Logger) http.Handler { + captured = cfg + return http.NotFoundHandler() + }) + + resp, err := mgr.CreateInstance(context.Background(), &CreateRequest{ + WorkspacePath: t.TempDir(), + NamespacesMCPToolsByServer: true, + }) + if err != nil { + t.Fatalf("CreateInstance: %v", err) + } + t.Cleanup(func() { _ = mgr.StopInstance(context.Background(), resp.ID) }) + + if captured == nil || !captured.NamespacesMCPToolsByServer { + t.Fatalf("captured instance config = %+v, want namespaced MCP tools", captured) + } +} diff --git a/apps/backend/internal/mcp/server/server.go b/apps/backend/internal/mcp/server/server.go index 3f07f22e75..6c9c301ff8 100644 --- a/apps/backend/internal/mcp/server/server.go +++ b/apps/backend/internal/mcp/server/server.go @@ -57,6 +57,8 @@ const ( const pluginToolArgumentsKey = "arguments" +const mcpToolNameSuffix = "_kandev" + // MCP payload keys reused across tool registrations. Extracted so a future // wire-protocol rename touches every tool in one place AND so goconst // doesn't flag the literals as repeated string occurrences. @@ -97,30 +99,42 @@ func normalizeMode(mode string) string { // Server wraps the MCP server with backend client for communication. type Server struct { - backend BackendClient - sessionID string - taskID string - disableAskQuestion bool - mode string // "task" (default), "task-title-pending", "config", or "office" - mcpProviders []string - profile mcpprofile.Context - mcpServer *server.MCPServer - sseServer *server.SSEServer - httpServer *server.StreamableHTTPServer - logger *logger.Logger - mcpLogger *zap.Logger // optional file logger for MCP debug traces - mu sync.RWMutex - running bool - attachmentMu sync.RWMutex - attachmentAttempt streams.MCPAttachmentAttempt - attachmentAttempts map[string]streams.MCPAttachmentAttempt - attachmentReporter func(streams.MCPAttachmentEvidence) - validatorMu sync.RWMutex - toolValidators map[string]toolArgumentValidator - pluginToolsUpdateMu sync.Mutex - pluginToolsMu sync.Mutex - pluginTools plugintools.Snapshot - pluginToolsReady bool + backend BackendClient + sessionID string + taskID string + disableAskQuestion bool + mode string // "task" (default), "task-title-pending", "config", or "office" + mcpProviders []string + profile mcpprofile.Context + namespacesMCPToolsByServer bool + mcpServer *server.MCPServer + sseServer *server.SSEServer + httpServer *server.StreamableHTTPServer + logger *logger.Logger + mcpLogger *zap.Logger // optional file logger for MCP debug traces + mu sync.RWMutex + running bool + attachmentMu sync.RWMutex + attachmentAttempt streams.MCPAttachmentAttempt + attachmentAttempts map[string]streams.MCPAttachmentAttempt + attachmentReporter func(streams.MCPAttachmentEvidence) + validatorMu sync.RWMutex + toolValidators map[string]toolArgumentValidator + pluginToolsUpdateMu sync.Mutex + pluginToolsMu sync.Mutex + pluginTools plugintools.Snapshot + pluginToolsReady bool +} + +// ServerOption configures the per-instance MCP transport. +type ServerOption func(*Server) + +// WithMCPToolNamespacingByServer enables presentation compatibility for an +// agent that appends the injected MCP server name to every tool. +func WithMCPToolNamespacingByServer(enabled bool) ServerOption { + return func(s *Server) { + s.namespacesMCPToolsByServer = enabled + } } // New creates a new MCP server for agentctl. @@ -152,11 +166,11 @@ func New(backend BackendClient, sessionID, taskID string, port int, log *logger. // The profile keeps base surfaces and additive capability groups separate so // callers can add or remove one context-specific group without copying a full // mode branch. -func NewWithProfile(backend BackendClient, sessionID, taskID string, port int, log *logger.Logger, mcpLogFile string, disableAskQuestion bool, profileContext mcpprofile.Context) *Server { +func NewWithProfile(backend BackendClient, sessionID, taskID string, port int, log *logger.Logger, mcpLogFile string, disableAskQuestion bool, profileContext mcpprofile.Context, options ...ServerOption) *Server { if disableAskQuestion { profileContext = profileContext.WithoutCapability(mcpprofile.CapabilityUserQuestion) } - s := newServerWithProfile(backend, sessionID, taskID, log, mcpLogFile, profileContext) + s := newServerWithProfile(backend, sessionID, taskID, log, mcpLogFile, profileContext, options...) s.sseServer = server.NewSSEServer(s.mcpServer, server.WithBaseURL(fmt.Sprintf("http://localhost:%d", port)), ) @@ -197,7 +211,7 @@ func newServer(backend BackendClient, sessionID, taskID string, log *logger.Logg return newServerWithProfile(backend, sessionID, taskID, log, mcpLogFile, mcpprofile.Legacy(mcpMode, disableAskQuestion, mcpProviders)) } -func newServerWithProfile(backend BackendClient, sessionID, taskID string, log *logger.Logger, mcpLogFile string, profileContext mcpprofile.Context) *Server { +func newServerWithProfile(backend BackendClient, sessionID, taskID string, log *logger.Logger, mcpLogFile string, profileContext mcpprofile.Context, options ...ServerOption) *Server { profileContext = mcpprofile.New(profileContext.Surface, profileContext.Capabilities, profileContext.Providers) s := &Server{ backend: backend, @@ -210,6 +224,11 @@ func newServerWithProfile(backend BackendClient, sessionID, taskID string, log * logger: log.WithFields(zap.String("component", "mcp-server")), attachmentAttempts: make(map[string]streams.MCPAttachmentAttempt), } + for _, option := range options { + if option != nil { + option(s) + } + } // Set up optional file logger for MCP debug traces if mcpLogFile != "" { @@ -238,7 +257,11 @@ func newServerWithProfile(backend BackendClient, sessionID, taskID string, log * hooks.AddAfterInitialize(func(ctx context.Context, _ any, _ *mcp.InitializeRequest, _ *mcp.InitializeResult) { s.observeMCPConnection(mcpConnectionID(ctx), streams.MCPAttachmentEvidenceInitializeObserved, 0, "") }) + hooks.AddBeforeCallTool(func(_ context.Context, _ any, request *mcp.CallToolRequest) { + s.restoreCanonicalToolName(request) + }) hooks.AddAfterListTools(func(ctx context.Context, _ any, _ *mcp.ListToolsRequest, result *mcp.ListToolsResult) { + s.presentTransportToolNames(result) s.observeMCPToolsList(mcpConnectionID(ctx), result.Tools) }) hooks.AddBeforeListTools(func(ctx context.Context, _ any, _ *mcp.ListToolsRequest) { @@ -258,6 +281,39 @@ func newServerWithProfile(backend BackendClient, sessionID, taskID string, log * return s } +func (s *Server) presentTransportToolNames(result *mcp.ListToolsResult) { + if !s.namespacesMCPToolsByServer || result == nil { + return + } + registeredTools := s.mcpServer.ListTools() + for index := range result.Tools { + name := result.Tools[index].Name + if !strings.HasSuffix(name, mcpToolNameSuffix) { + continue + } + transportName := strings.TrimSuffix(name, mcpToolNameSuffix) + // Keep plugin tools named "name" distinct from canonical tools named + // "name_kandev" when both are present in the live registry. + if _, collision := registeredTools[transportName]; collision { + continue + } + result.Tools[index].Name = transportName + } +} + +func (s *Server) restoreCanonicalToolName(request *mcp.CallToolRequest) { + if !s.namespacesMCPToolsByServer || request == nil { + return + } + if s.mcpServer.GetTool(request.Params.Name) != nil { + return + } + canonicalName := request.Params.Name + mcpToolNameSuffix + if s.mcpServer.GetTool(canonicalName) != nil { + request.Params.Name = canonicalName + } +} + func modeForProfile(profileContext mcpprofile.Context) string { switch profileContext.Surface { case mcpprofile.SurfaceConfiguration: diff --git a/apps/backend/internal/mcp/server/tool_name_presentation_test.go b/apps/backend/internal/mcp/server/tool_name_presentation_test.go new file mode 100644 index 0000000000..4857f45497 --- /dev/null +++ b/apps/backend/internal/mcp/server/tool_name_presentation_test.go @@ -0,0 +1,152 @@ +package mcp + +import ( + "context" + "encoding/json" + "strings" + "testing" + + mcpprofile "github.com/kandev/kandev/internal/mcp/profile" + ws "github.com/kandev/kandev/pkg/websocket" + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestMCPToolNamePresentation_AllProfiles covers +// AC-TASKS-MCP-TOOL-NAMES-001.1 and .3. It models Auggie's client behavior by +// appending the injected server name after the per-instance transport returns +// its tool list. +func TestMCPToolNamePresentation_AllProfiles(t *testing.T) { + profiles := []struct { + name string + profile mcpprofile.Context + }{ + {name: ModeTask, profile: mcpprofile.Legacy(ModeTask, false, nil)}, + {name: ModeConfig, profile: mcpprofile.Legacy(ModeConfig, false, nil)}, + {name: ModeExternal, profile: mcpprofile.Legacy(ModeExternal, true, nil)}, + {name: ModeOffice, profile: mcpprofile.Legacy(ModeOffice, false, nil)}, + {name: ModeAutomation, profile: mcpprofile.NewAutomation()}, + } + + for _, tc := range profiles { + t.Run(tc.name, func(t *testing.T) { + s := namespacedTestServer(t, &testBackend{}, tc.profile) + canonical := s.mcpServer.ListTools() + toolNames := transportToolNames(t, s) + + for canonicalName := range canonical { + if !strings.HasSuffix(canonicalName, "_kandev") { + continue + } + transportName := strings.TrimSuffix(canonicalName, "_kandev") + assert.Contains(t, toolNames, transportName, "missing transport alias for %q", canonicalName) + assert.NotContains(t, toolNames, canonicalName+"_kandev") + assert.Equal(t, canonicalName, transportName+"_kandev", + "Auggie model name for %q must be canonical", canonicalName) + } + + if tc.name == ModeTask { + assert.Contains(t, toolNames, "ask_user_question") + assert.Contains(t, toolNames, "get_task_plan") + } + }) + } +} + +// TestMCPToolNamePresentation_DefaultFalsePreservesCanonicalNames covers +// AC-TASKS-MCP-TOOL-NAMES-001.2 for a representative non-namespacing client. +func TestMCPToolNamePresentation_DefaultFalsePreservesCanonicalNames(t *testing.T) { + s := NewWithProfile( + &testBackend{}, "test-session", "test-task", 10005, newTestLogger(t), "", false, + mcpprofile.Legacy(ModeTask, false, nil), + ) + names := transportToolNames(t, s) + + assert.Contains(t, names, "ask_user_question_kandev") + assert.Contains(t, names, "get_task_plan_kandev") + assert.NotContains(t, names, "ask_user_question") + assert.NotContains(t, names, "get_task_plan") +} + +// TestMCPToolNamePresentation_RestoresCanonicalCallName covers +// AC-TASKS-MCP-TOOL-NAMES-001.4. The call is deliberately sent with the bare +// transport name that Auggie sends after removing its server namespace. +func TestMCPToolNamePresentation_RestoresCanonicalCallName(t *testing.T) { + backend := &testBackend{response: map[string]interface{}{"workspaces": []interface{}{}}} + s := namespacedTestServer(t, backend, mcpprofile.Legacy(ModeTask, false, nil)) + + response := s.mcpServer.HandleMessage(context.Background(), []byte(`{ + "jsonrpc":"2.0","id":1,"method":"tools/call", + "params":{"name":"list_workspaces","arguments":{}} + }`)) + raw, err := json.Marshal(response) + require.NoError(t, err) + require.NotContains(t, string(raw), "tool 'list_workspaces' not found") + assert.Equal(t, ws.ActionMCPListWorkspaces, backend.lastAction) +} + +// TestMCPToolNamePresentation_UsesLiveRegistry covers the dynamic profile +// replacement and plugin-name boundaries described by the system design. +func TestMCPToolNamePresentation_UsesLiveRegistry(t *testing.T) { + s := namespacedTestServer(t, &testBackend{}, mcpprofile.Legacy(ModeTask, false, nil)) + s.SetMode(ModeConfig) + s.mcpServer.AddTool(mcplib.NewTool("kandev_plugin_echo"), func(context.Context, mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + return mcplib.NewToolResultText("ok"), nil + }) + + names := transportToolNames(t, s) + assert.Contains(t, names, "kandev_plugin_echo") + assert.NotContains(t, names, "kandev_plugin_echo_kandev") + assert.Contains(t, names, "list_workflows") + assert.NotContains(t, names, "list_workflows_kandev_kandev") +} + +func TestMCPToolNamePresentation_PreservesPluginAliasCollision(t *testing.T) { + s := namespacedTestServer(t, &testBackend{}, mcpprofile.Legacy(ModeTask, false, nil)) + for _, name := range []string{"plugin_echo", "plugin_echo_kandev"} { + s.mcpServer.AddTool(mcplib.NewTool(name), func(context.Context, mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + return mcplib.NewToolResultText("ok"), nil + }) + } + + names := transportToolNames(t, s) + counts := make(map[string]int) + for _, name := range names { + counts[name]++ + } + + assert.Equal(t, 1, counts["plugin_echo"]) + assert.Equal(t, 1, counts["plugin_echo_kandev"]) +} + +func namespacedTestServer(t *testing.T, backend BackendClient, profile mcpprofile.Context) *Server { + t.Helper() + return NewWithProfile( + backend, "test-session", "test-task", 10005, newTestLogger(t), "", false, profile, + WithMCPToolNamespacingByServer(true), + ) +} + +func transportToolNames(t *testing.T, s *Server) []string { + t.Helper() + response := s.mcpServer.HandleMessage(context.Background(), []byte(`{ + "jsonrpc":"2.0","id":1,"method":"tools/list","params":{} + }`)) + raw, err := json.Marshal(response) + require.NoError(t, err) + var decoded struct { + Result struct { + Tools []struct { + Name string `json:"name"` + } `json:"tools"` + } `json:"result"` + } + require.NoError(t, json.Unmarshal(raw, &decoded)) + + names := make([]string, 0, len(decoded.Result.Tools)) + for _, tool := range decoded.Result.Tools { + names = append(names, tool.Name) + } + return names +} diff --git a/docs/decisions/2026-08-31-agent-aware-mcp-tool-names.md b/docs/decisions/2026-08-31-agent-aware-mcp-tool-names.md new file mode 100644 index 0000000000..57cdd83be6 --- /dev/null +++ b/docs/decisions/2026-08-31-agent-aware-mcp-tool-names.md @@ -0,0 +1,79 @@ +# ADR-2026-08-31-agent-aware-mcp-tool-names: Preserve Canonical MCP Tool Names Through Agent Namespacing + +**Status:** accepted +**Date:** 2026-08-31 +**Area:** backend, agentctl, protocol + +## Context + +Kandev registers built-in MCP tools with a durable `_kandev` suffix and injects +the server as `kandev`. Most supported clients present the registered name +unchanged. Auggie instead presents every tool as +`_`, so the model sees +`ask_user_question_kandev_kandev` while the Kandev system prompt instructs it to +call `ask_user_question_kandev`. + +Removing the registered suffix would break clients that do not add a server +namespace. Changing every prompt to the doubled form makes the Kandev +contract depend on one external client. The external Auggie implementation is +outside this repository. + +## Decision + +1. Keep canonical built-in registrations and prompt references in the + `*_kandev` form. +2. Keep the injected MCP server name `kandev`. +3. Declare client-side server namespacing as an explicit agent runtime + capability. Auggie opts in. The default is off. +4. For an opted-in per-instance Kandev MCP endpoint, expose built-in canonical + tools without one trailing `_kandev` on `tools/list`. Reverse that mapping on + `tools/call` before registry lookup, validation, logging, and handler + dispatch. +5. Resolve aliases against the live canonical registry. An exact registered + name wins, and unknown names retain the normal tool-not-found behavior. +6. Keep system prompts agent-neutral because every model continues to see the + canonical name after the client applies its presentation behavior. + +## Consequences + +- Auggie models see and call the same canonical names documented by Kandev, + without a doubled suffix. +- Cursor ACP, Codex ACP, Claude ACP, and other non-namespacing clients keep their + existing tool lists and calls. +- The MCP server adds a small presentation and reverse-routing layer, while its + canonical registry and handlers remain unchanged. +- Every executor must preserve the runtime capability through agentctl instance + creation. Tests must pin both the producer and the MCP-server consumer. +- Plugin-contributed names that do not end in `_kandev` remain governed by + their separate naming contract. + +## Alternatives Considered + +### Remove `_kandev` from every registered tool + +Rejected because non-namespacing clients rely on the suffix for +disambiguation. Existing prompts, tests, and external integrations also use the +canonical names. + +### Teach prompts to use doubled names for Auggie + +Rejected because handlers remain canonical. Each tool reference then needs an +agent-specific prompt variant. The presentation detail of the external client +also becomes the public Kandev contract. + +### Rename the injected server + +Rejected because any non-empty server name still changes the name that Auggie +presents. The change also affects server identity, deduplication, attachment +evidence, and user configuration. + +### Branch on `AgentType == "auggie"` inside agentctl + +Rejected because tool presentation is a client capability that another agent +can share. The agent definition is the durable source. Transport and MCP +packages consume the declared behavior without naming a provider. + +### Change the Auggie CLI + +Rejected because it is an external dependency and Kandev must remain compatible +with released clients. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 8d3ecec765..9bf23d08cb 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -224,3 +224,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-08-30-environment-owned-git-status | [Project Current Git Status From the Task Environment](2026-08-30-environment-owned-git-status.md) | proposed | backend, frontend, protocol | 2026-08-30 | | 2026-08-31-local-worktree-refresh-best-effort | [Local Worktree Refresh Is Best Effort](2026-08-31-local-worktree-refresh-best-effort.md) | accepted | backend, security, operations | 2026-08-31 | | 2026-08-31-passthrough-running-publication | [Defer Passthrough Running Publication Until Guard Release](2026-08-31-passthrough-running-publication.md) | accepted | backend | 2026-08-31 | +| 2026-08-31-agent-aware-mcp-tool-names | [Preserve Canonical MCP Tool Names Through Agent Namespacing](2026-08-31-agent-aware-mcp-tool-names.md) | accepted | backend, agentctl, protocol | 2026-08-31 | diff --git a/docs/plans/auggie-mcp-tool-names/plan.md b/docs/plans/auggie-mcp-tool-names/plan.md new file mode 100644 index 0000000000..46bd84062a --- /dev/null +++ b/docs/plans/auggie-mcp-tool-names/plan.md @@ -0,0 +1,128 @@ +--- +created: 2026-08-31 +status: complete +requirements: + - REQ-TASKS-MCP-TOOL-NAMES-001 +system_design: + - ../../specs/tasks/system-design/mcp-tool-name-stability.md +legacy_specs: [] +--- + +# Implementation Plan: Auggie MCP Tool Names + +## Overview + +Add one agent runtime capability and carry it to the per-instance MCP server. +Then adapt only the transport-facing tool names and incoming calls of that server. +The implementation order keeps the canonical registry and system prompts stable +while proving the capability reaches every executor before the MCP boundary +uses it. + +## Confirmed root cause + +Kandev registers built-in tools with a `_kandev` suffix and injects its local +server as `kandev`. Auggie appends the server name to each presented tool. +This operation produces `*_kandev_kandev`. Cursor ACP and other +clients do not add that namespace. Current focused baseline tests pass, so the +regression test must first encode the missing Auggie-specific round trip. + +## Scope + +### In scope + +- Declare the Auggie server-namespacing behavior in the agent runtime + configuration. +- Propagate the capability through local, Docker, SSH, and Sprites agentctl + instance creation. +- Translate built-in `*_kandev` names at the per-instance MCP list and call + boundaries for agents that enable the capability. +- Preserve canonical registry keys, validators, handler names, server identity, + and system-prompt references. +- Add regression coverage for `ask_user_question_kandev`, + `get_task_plan_kandev`, all built-in profile tools, and representative + non-namespacing agents. + +### Out of scope + +- Modifying the external Auggie CLI. +- Renaming canonical built-in or plugin-contributed tools. +- Changing MCP schemas, profiles, permissions, or prompt content. +- Adding frontend or browser behavior. + +## Technical approach + +### Agent capability and instance contract + +- Add `NamespacesMCPToolsByServer` to + `apps/backend/internal/agent/agents.RuntimeConfig` and enable it only in + `Auggie.Runtime()`. +- Copy the value through the lifecycle executor request builders, + `internal/agent/runtime/agentctl.CreateInstanceRequest`, the public agentctl + create request, `config.InstanceOverrides`, and `config.InstanceConfig`. +- Keep the zero value false and add producer/serialization/consumer assertions + so Cursor ACP, Codex ACP, and Claude ACP remain unchanged. + +### MCP transport presentation + +- Add an immutable per-instance presentation setting to + `apps/backend/internal/mcp/server.Server`, configured in + `apps/backend/cmd/agentctl/main.go` before routes are served. +- Use the existing MCP hooks around `tools/list` and `tools/call`. +- For opted-in clients, strip one trailing `_kandev` from built-in definitions. + Restore the suffix before registry lookup. +- Resolve against the live canonical registry, prefer exact registered names, + and leave non-suffixed plugin tools untouched. +- Keep `server.MCPServer.ListTools()`, argument validators, `wrapHandler`, and + system-prompt synchronization canonical. + +## Tests + +- **AC-TASKS-MCP-TOOL-NAMES-001.1 and .3:** Add MCP transport coverage for task, + configuration, external, Office, and automation profiles. Simulate the + server-name append from Auggie. Assert that each built-in result has exactly + the canonical name. Include explicit assertions for + `ask_user_question_kandev` and `get_task_plan_kandev`. +- **AC-TASKS-MCP-TOOL-NAMES-001.2:** Assert representative false-capability + agents receive the unchanged canonical list. +- **AC-TASKS-MCP-TOOL-NAMES-001.4:** Send a bare transport call through the MCP + request boundary and assert the canonical handler and backend action execute. +- **AC-TASKS-MCP-TOOL-NAMES-001.5:** Extend request, configuration, and executor + tests so new/load/reset session paths share the configured per-instance MCP + endpoint behavior. +- Keep the existing sysprompt-to-registry synchronization suite green to prove + prompt references did not change. + +## E2E tests + +No browser E2E is required because this is an agent/MCP protocol boundary with +no web UI change. When an authenticated Auggie runtime is available, do the +documented live ACP-log test after the automated suite. This evidence depends +on the environment. It does not replace the regression tests. + +## Work orders + +- [x] [Task 01: Preserve canonical names for server-namespacing agents](task-01-preserve-canonical-tool-names.md) — complete + +## Verification results + +- Focused work-order packages passed: 3,278 tests across agents, agentctl, + lifecycle, instance config, instance manager, MCP server, and sysprompt. +- `go test ./cmd/agentctl`: passed 94 tests. +- Race-enabled MCP and lifecycle tests: passed 2,451 tests. +- The raw `make -C apps/backend test` run was affected by the managed session's + inherited `KANDEV_INTERNAL_CONFIG_FILE` and failed only unrelated config + discovery tests. The same target passed after unsetting the managed config + handoff variables, with all backend packages green. +- `make -C apps/backend lint`: passed with zero issues. +- `python3 scripts/lint-spec-files.py --all`, `gofmt -l`, and `git diff --check`: + passed. + +## Risks + +- A propagation omission in one executor will leave that launch mode broken. + every request builder needs a focused assertion. +- Hook ordering must restore the canonical name before MCP lookup and argument + validation while reporting the definitions actually returned to the client. +- Dynamic tool replacement must not cache stale aliases. +- Plugin tools use a different canonical namespace and must not be rewritten by + the trailing-suffix adapter. diff --git a/docs/plans/auggie-mcp-tool-names/task-01-preserve-canonical-tool-names.md b/docs/plans/auggie-mcp-tool-names/task-01-preserve-canonical-tool-names.md new file mode 100644 index 0000000000..5aa89cc243 --- /dev/null +++ b/docs/plans/auggie-mcp-tool-names/task-01-preserve-canonical-tool-names.md @@ -0,0 +1,132 @@ +--- +id: "01-preserve-canonical-tool-names" +title: "Preserve canonical names for server-namespacing agents" +status: complete +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-TASKS-MCP-TOOL-NAMES-001 +acceptance_criteria: + - AC-TASKS-MCP-TOOL-NAMES-001.1 + - AC-TASKS-MCP-TOOL-NAMES-001.2 + - AC-TASKS-MCP-TOOL-NAMES-001.3 + - AC-TASKS-MCP-TOOL-NAMES-001.4 + - AC-TASKS-MCP-TOOL-NAMES-001.5 +system_design: + - ../../specs/tasks/system-design/mcp-tool-name-stability.md +--- + +# Task 01: Preserve Canonical Names for Server-Namespacing Agents + +## Summary + +Declare and propagate the MCP server-namespacing capability of the agent. Then +add a reversible transport-name adapter to the per-instance Kandev MCP server. +The canonical registry, prompts, validation, and handler routing remain +unchanged. + +## In scope + +- Add the runtime capability and enable it for Auggie only. +- Propagate it through every persistent agentctl instance creation path. +- Adapt `tools/list` and `tools/call` at the local Kandev MCP endpoint. +- Add red-green regression coverage for all built-in MCP profiles, canonical + dispatch, and non-namespacing agents. + +## Out of scope + +- External agent-client changes. +- Prompt, schema, profile, permission, or canonical tool-name changes. +- Plugin tool-name redesign. +- Browser E2E coverage. + +## Acceptance + +- The effective model-facing Auggie catalog contains + `ask_user_question_kandev`, `get_task_plan_kandev`, and every other built-in + canonical tool without a doubled `_kandev_kandev` suffix. +- Cursor ACP, Codex ACP, Claude ACP, and other default-false agents retain their + canonical `*_kandev` catalog unchanged. +- The bare MCP call from a namespacing client is restored to the canonical + registered name before validation and dispatch. All backend tests and lint + pass. + +## Verification + +Follow TDD, then run: + +```bash +rtk go test ./internal/agent/agents ./internal/agent/runtime/agentctl ./internal/agent/runtime/lifecycle ./internal/agentctl/server/config ./internal/agentctl/server/instance ./internal/mcp/server ./internal/sysprompt +rtk make -C apps/backend test +rtk make -C apps/backend lint +``` + +Run the first command from `apps/backend`. Run the two `make` commands from the +repository root. + +When a live Auggie login is available, trigger a user question and inspect the +ACP logs with: + +```bash +rtk grep -rho "ask_user_question[a-z_]*\|get_task_plan[a-z_]*" ~/.kandev/logs/acp/ +``` + +## Files likely touched + +- `apps/backend/internal/agent/agents/agent.go` +- `apps/backend/internal/agent/agents/auggie.go` +- `apps/backend/internal/agent/agents/new_acp_agents_test.go` +- `apps/backend/internal/agent/runtime/agentctl/control.go` +- `apps/backend/internal/agent/runtime/agentctl/control_test.go` +- `apps/backend/internal/agent/runtime/lifecycle/container.go` +- `apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go` +- `apps/backend/internal/agent/runtime/lifecycle/executor_docker.go` +- `apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go` +- `apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go` +- `apps/backend/internal/agentctl/server/instance/instance.go` +- `apps/backend/internal/agentctl/server/instance/manager.go` +- `apps/backend/internal/agentctl/server/config/config.go` +- `apps/backend/cmd/agentctl/main.go` +- `apps/backend/internal/mcp/server/server.go` +- `apps/backend/internal/mcp/server/tool_name_presentation_test.go` +- Relevant existing executor, instance, config, and sysprompt synchronization + tests alongside those files. + +## Dependencies + +None. + +## Risks + +- Every executor request builder must carry the capability. +- The list and call mappings must remain one-to-one across live tool-set + replacement. +- Exact registered names must win over aliases to avoid dispatching a plugin or + future built-in tool incorrectly. + +## Parallelism + +`sequential` + +## Inputs + +- `REQ-TASKS-MCP-TOOL-NAMES-001` and all acceptance criteria. +- `docs/specs/tasks/system-design/mcp-tool-name-stability.md`. +- `ADR-2026-08-31-agent-aware-mcp-tool-names`. +- Existing MCP registration, hook, instance configuration, executor request, + and sysprompt synchronization tests. + +## Results + +Completed on 2026-08-31. + +- Added the Auggie-only runtime capability and propagated it through standalone, + container, Docker reconnect, Sprites, and SSH agentctl instance requests. +- Added per-instance MCP transport translation for list and call boundaries. + Canonical registry names, validators, handlers, prompts, and plugin names + remain unchanged. +- Focused packages passed 3,278 tests; `cmd/agentctl` passed 94 tests; and + race-enabled MCP/lifecycle tests passed 2,451 tests. +- The full backend test target passed after removing the managed-session config + handoff variables. Backend lint passed with zero issues. diff --git a/docs/specs/tasks/README.md b/docs/specs/tasks/README.md index 6636ce5c21..0778df29e0 100644 --- a/docs/specs/tasks/README.md +++ b/docs/specs/tasks/README.md @@ -54,6 +54,7 @@ signals, and task-scoped scheduling contracts. - [Task Labels](requirements/labels.md) - [Link Existing Task to External References](requirements/link-existing-task-github-issue.md) - [MCP-Created Task Agent Profile Default](requirements/mcp-task-agent-profile-default.md) +- [MCP Tool Name Stability](requirements/mcp-tool-name-stability.md) - [Missing task route recovery](requirements/missing-task-route-recovery.md) - [Task model unification](requirements/model-unification.md) - [Multi-branch tasks](requirements/multi-branch.md) @@ -119,6 +120,7 @@ signals, and task-scoped scheduling contracts. - [External task ID idempotency operations](system-design/external-id-idempotency-operations.md) - [External task ID idempotency](system-design/external-id-idempotency.md) - [Task model unification](system-design/model-unification.md) +- [MCP Tool Name Stability](system-design/mcp-tool-name-stability.md) - [Remote Contribution Tasks](system-design/remote-contribution-tasks.md) - [Passthrough Queued Prompt Dispatch](system-design/passthrough-queued-prompt-dispatch.md) - [Task Archive Confirmation](system-design/archive-confirmation.md) @@ -137,7 +139,7 @@ signals, and task-scoped scheduling contracts. ## Migration record -Migration remains in progress. The five requirements above now have +Migration remains in progress. The six requirements above now have authoritative, wrapper-free requirement/design pairs. Other migrated files still need the same extraction before this system can return to a complete migration state. diff --git a/docs/specs/tasks/requirements/mcp-tool-name-stability.md b/docs/specs/tasks/requirements/mcp-tool-name-stability.md new file mode 100644 index 0000000000..c3e680440b --- /dev/null +++ b/docs/specs/tasks/requirements/mcp-tool-name-stability.md @@ -0,0 +1,75 @@ +--- +status: active +system: tasks +created: 2026-08-31 +owners: + - kandev +--- + +# MCP Tool Name Stability Requirements + +## Overview + +Kandev gives agents built-in MCP tools whose canonical names end in +`_kandev`. The task system owns the model-facing names used by its system +prompts and session tool surface. The agent system owns the capability data +that describes how a particular client presents those names. + +An agent client can add the MCP server name to every tool before presenting the +tool to its model. Kandev must compensate for that behavior without changing +the canonical tool names used by clients that present server definitions +unchanged. + +## Terminology + +- **Canonical tool name:** The registered Kandev tool name, including its + trailing `_kandev` suffix. +- **Server-namespacing agent:** An agent client that presents an MCP tool as + `_`. The client removes that namespace before + it sends `tools/call` to the server. +- **Transport-facing tool name:** The name returned by the per-session Kandev + MCP endpoint before an agent client applies its own presentation rules. + +## Requirements + +### REQ-TASKS-MCP-TOOL-NAMES-001: Stable model-facing Kandev tool names + +**Intent:** Let every supported agent model discover and call the canonical +Kandev tool name on its first attempt. Client-side MCP namespacing must not +change that name. + +#### Acceptance criteria + +- **AC-TASKS-MCP-TOOL-NAMES-001.1:** When a server-namespacing agent receives a + built-in canonical name, the system shall present exactly one `_kandev` + suffix to the model. +- **AC-TASKS-MCP-TOOL-NAMES-001.2:** When an agent does not namespace MCP tools + by server name, the system shall present the unchanged canonical tool name to + the model. +- **AC-TASKS-MCP-TOOL-NAMES-001.3:** For every built-in Kandev tool in every MCP + profile, the name described by the Kandev system context shall match the name + presented to the model. +- **AC-TASKS-MCP-TOOL-NAMES-001.4:** When a server-namespacing agent calls the + model-facing name, the MCP server shall use the canonical registered name. + The existing handler contract shall receive the request. +- **AC-TASKS-MCP-TOOL-NAMES-001.5:** New sessions, loaded sessions, resumed + sessions, and reset sessions shall apply the same naming behavior for the + selected agent. + +## Compatibility + +- `ask_user_question_kandev`, `get_task_plan_kandev`, and all other built-in + canonical names retain their existing spelling. +- The injected MCP server retains the name `kandev`. +- Agents that do not declare server namespacing retain the current tool list + and call behavior. +- Tool descriptions and Kandev system prompts continue to use canonical names. + +## Out of scope + +- Changing the behavior of the external Auggie CLI or any other agent client. +- Globally removing the `_kandev` suffix from registered tools. +- Renaming the injected Kandev MCP server. +- Changing plugin-contributed tool names that do not use the trailing + `_kandev` convention. +- Changing tool schemas, permissions, MCP profiles, or handler behavior. diff --git a/docs/specs/tasks/system-design/mcp-tool-name-stability.md b/docs/specs/tasks/system-design/mcp-tool-name-stability.md new file mode 100644 index 0000000000..8e97ca5d4e --- /dev/null +++ b/docs/specs/tasks/system-design/mcp-tool-name-stability.md @@ -0,0 +1,162 @@ +--- +status: current +system: tasks +requirements: + - REQ-TASKS-MCP-TOOL-NAMES-001 +--- + +# MCP Tool Name Stability System Design + +## Purpose and boundaries + +This design preserves canonical `*_kandev` registrations while adapting the +per-instance MCP transport for agents that add the server name themselves. It +covers built-in tools on task, configuration, external, Office, and automation +profiles because those profiles share the same MCP registry and transport. + +The agent system declares client behavior. The task system consumes that +capability during construction of the session-bound MCP server for the agent. The +ACP protocol and the external agent client remain unchanged. + +## Requirement mapping + +| Requirement | Design section | +| --- | --- | +| `REQ-TASKS-MCP-TOOL-NAMES-001` | [Capability contract](#capability-contract), [Transport name translation](#transport-name-translation), [Control flow](#control-flow), [Verification](#verification) | + +## Confirmed current behavior + +- `apps/backend/internal/mcp/server` registers built-in tools with canonical + names such as `ask_user_question_kandev` and `get_task_plan_kandev`. +- `apps/backend/internal/agentctl/server/config` and + `apps/backend/internal/agentctl/server/api` inject the local server with the + name `kandev`. +- `apps/backend/internal/agent/agents.Auggie` opts into assumed HTTP and SSE MCP + transports but does not describe its client-side tool namespacing behavior. +- The Auggie client appends `_kandev` to the already canonical tool name, so the + model sees `*_kandev_kandev`. Other supported clients preserve the registered + name. + +The focused baseline packages passed 1,501 tests before this design was +written. + +## Capability contract + +`agents.RuntimeConfig` gains a boolean capability named +`NamespacesMCPToolsByServer`. Auggie sets it to `true`. The zero value is +`false`, so Cursor ACP, Codex ACP, Claude ACP, and every existing agent retain +their current behavior unless they explicitly opt in. + +This is a declared runtime characteristic, not an `AgentType == "auggie"` +branch. ACP does not advertise this presentation behavior during initialize, +so Kandev cannot negotiate it from the protocol handshake. + +The lifecycle copies the value through each executor +`agentctl.CreateInstanceRequest`, the control-client JSON contract, +`instance.CreateRequest`, `config.InstanceOverrides`, and +`config.InstanceConfig`. The capability terminates at construction of the +per-instance Kandev MCP server. It does not alter user-configured MCP servers. + +## Transport name translation + +The MCP server keeps its internal registry, validators, tool profiles, and +handlers keyed by canonical names. A per-instance presentation policy changes +only the local Kandev MCP transport: + +1. If server namespacing is enabled, remove exactly one trailing `_kandev` on + `tools/list`. Return the modified built-in definition. +2. Auggie appends the injected server name and presents the resulting canonical + name to its model. For example, `ask_user_question` plus `kandev` becomes + `ask_user_question_kandev`. +3. When the model calls that name, Auggie removes its server namespace and + sends `ask_user_question` in `tools/call`. +4. If the canonical registered tool exists, map the bare transport name before + MCP registry lookup. The mapped name is `ask_user_question_kandev`. +5. Argument validation, logging, and the registered handler receive the + canonical name. + +The translation reads the current registry at each list or call boundary. +Thus, `SetMode`, profile replacement, and dynamic tool updates cannot leave a +stale alias table. An exact registered name wins over a derived alias. Tests +assert that each current built-in `*_kandev` name has an unambiguous round trip. + +Plugin-contributed tools use their independent `kandev__` +namespace. They do not match the trailing-suffix translation and keep their +current transport behavior. + +## Control flow + +```text +Auggie RuntimeConfig (namespaces = true) + -> lifecycle executor request + -> agentctl instance configuration + -> per-instance MCP presentation policy + -> tools/list: canonical -> bare + -> Auggie presentation: bare + _kandev -> canonical model name + -> tools/call: Auggie removes _kandev -> bare + -> MCP call boundary: bare -> canonical + -> existing validator and handler +``` + +For an agent whose capability is false, both translation steps are no-ops. ACP +`session/new`, `session/load`, and session reset continue to inject a server +named `kandev`. Their existing transport filtering is unchanged. + +## Prompt consistency + +`apps/backend/internal/sysprompt` continues to emit canonical `*_kandev` +references. No agent-specific prompt variant is needed because the transport +adaptation makes the model-facing name canonical for both capability values. + +The existing prompt-to-registry synchronization tests remain authoritative. +New coverage composes each translated tool name with the injected server name +and proves that the result equals the canonical prompt and registry name. + +## Failure and recovery + +- A missing capability propagation step would restore doubled names for that + executor. Producer, JSON, instance-config, and consumer-boundary tests cover + the complete chain. +- A name that cannot map to a canonical registration follows the existing MCP + tool-not-found path. Kandev does not guess or dispatch a different tool. +- A future alias collision fails the all-profile round-trip test. It does not + silently change the handler of an existing tool. +- Session reconnect and reset rebuild no persistent state. The immutable + instance capability applies again during tool lists and calls. + +## Persistence and security + +The capability is derived from the selected built-in agent at launch and is not +persisted as user-controlled data. No database migration is required. + +The translation changes names only. Existing session identity, profile +authority, argument validation, permissions, and backend dispatch checks remain +in force. + +## Observability + +The existing MCP attachment catalog records the tool definitions returned by +the per-instance endpoint. ACP debug logs remain the manual evidence for the +final model-facing name and call result. A live Auggie test must show +`ask_user_question_kandev` and `get_task_plan_kandev`, with no +`_kandev_kandev` occurrence. + +## Verification + +- Agent tests pin the Auggie capability to `true` and representative + non-namespacing agents to `false`. +- Lifecycle and agentctl contract tests pin propagation to MCP server + construction. +- MCP transport tests enumerate every built-in profile, prove the list-name + round trip, and prove that a bare incoming call reaches its canonical + handler. +- System-prompt synchronization tests prove canonical prompt names remain + unchanged. +- The backend test and lint targets provide the final regression gate. + +## Related decisions + +- [Preserve Canonical MCP Tool Names Through Agent Namespacing](../../../decisions/2026-08-31-agent-aware-mcp-tool-names.md) +- [Task model unification](../../../decisions/0004-task-model-unification.md) +- [Per-CLI MCP server injection for passthrough mode](../../../decisions/0014-passthrough-mcp-injection-strategies.md) +- [Compose MCP Tool Profiles From Typed Context](../../../decisions/2026-08-08-mcp-tool-profiles.md)