fix: preserve canonical MCP tool names for Auggie (#3196)
This commit is contained in:
@@ -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)",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <kandev-home>/agent-sessions/<instance>/, so an
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
`<registered-name>_<server-name>`, 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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
`<server tool name>_<server name>`. 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.
|
||||
@@ -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_<plugin>_<action>`
|
||||
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)
|
||||
Reference in New Issue
Block a user