diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 9ff4b6c6..8ef29eb9 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -19,6 +19,7 @@ import ( "neo-code/internal/tools" "neo-code/internal/tools/bash" "neo-code/internal/tools/filesystem" + "neo-code/internal/tools/mcp" "neo-code/internal/tools/webfetch" "neo-code/internal/tui" ) @@ -178,10 +179,30 @@ func buildToolRegistry(cfg config.Config) (*tools.Registry, error) { } if mcpRegistry != nil { toolRegistry.SetMCPRegistry(mcpRegistry) + toolRegistry.SetMCPExposureFilter(mcp.NewExposureFilter(mcp.ExposureFilterConfig{ + Allowlist: cfg.Tools.MCP.Exposure.Allowlist, + Denylist: cfg.Tools.MCP.Exposure.Denylist, + Agents: buildMCPAgentExposureRules(cfg.Tools.MCP.Exposure.Agents), + })) } return toolRegistry, nil } +// buildMCPAgentExposureRules 将配置层的 agent 过滤规则转换为 tools/mcp 层输入。 +func buildMCPAgentExposureRules(configs []config.MCPAgentExposureConfig) []mcp.AgentExposureRule { + if len(configs) == 0 { + return nil + } + rules := make([]mcp.AgentExposureRule, 0, len(configs)) + for _, item := range configs { + rules = append(rules, mcp.AgentExposureRule{ + Agent: item.Agent, + Allowlist: append([]string(nil), item.Allowlist...), + }) + } + return rules +} + func buildToolManager(registry *tools.Registry) (tools.Manager, error) { engine, err := security.NewRecommendedPolicyEngine() if err != nil { diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index d32ac815..955a617c 100644 --- a/internal/app/bootstrap_test.go +++ b/internal/app/bootstrap_test.go @@ -372,6 +372,75 @@ func TestBuildToolRegistryIncludesMCPFromConfig(t *testing.T) { } } +func TestBuildToolRegistryAppliesMCPExposureConfig(t *testing.T) { + t.Parallel() + + cfg := config.StaticDefaults().Clone() + cfg.Workdir = t.TempDir() + cfg.Tools.MCP.Servers = []config.MCPServerConfig{ + { + ID: "docs", + Enabled: true, + Source: "stdio", + Stdio: config.MCPStdioConfig{ + Command: "mock", + }, + }, + { + ID: "admin", + Enabled: true, + Source: "stdio", + Stdio: config.MCPStdioConfig{ + Command: "mock", + }, + }, + } + cfg.Tools.MCP.Exposure = config.MCPExposureConfig{ + Allowlist: []string{"docs"}, + Agents: []config.MCPAgentExposureConfig{ + {Agent: "planner", Allowlist: []string{"docs.search"}}, + }, + } + + originalRegister := registerMCPStdioServer + t.Cleanup(func() { registerMCPStdioServer = originalRegister }) + registerMCPStdioServer = func(registry *mcp.Registry, cfg config.Config, server config.MCPServerConfig) error { + client := &stubMCPServerClient{ + tools: []mcp.ToolDescriptor{ + {Name: "search", Description: "search docs", InputSchema: map[string]any{"type": "object"}}, + }, + } + if err := registry.RegisterServer(server.ID, "stdio", server.Version, client); err != nil { + return err + } + return registry.RefreshServerTools(context.Background(), server.ID) + } + + registry, err := buildToolRegistry(cfg) + if err != nil { + t.Fatalf("buildToolRegistry() error = %v", err) + } + + specs, err := registry.ListAvailableSpecs(context.Background(), tools.SpecListInput{Agent: "planner"}) + if err != nil { + t.Fatalf("ListAvailableSpecs() error = %v", err) + } + + foundDocs := false + foundAdmin := false + for _, spec := range specs { + if spec.Name == "mcp.docs.search" { + foundDocs = true + } + if spec.Name == "mcp.admin.search" { + foundAdmin = true + } + } + if !foundDocs || foundAdmin { + t.Fatalf("expected only docs MCP tool to be exposed, got %+v", specs) + } +} + func TestBuildToolRegistryReturnsMCPSourceError(t *testing.T) { t.Parallel() diff --git a/internal/config/model.go b/internal/config/model.go index f9fb9200..9f0bb831 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -112,7 +112,19 @@ type WebFetchConfig struct { } type MCPConfig struct { - Servers []MCPServerConfig `yaml:"servers,omitempty"` + Servers []MCPServerConfig `yaml:"servers,omitempty"` + Exposure MCPExposureConfig `yaml:"exposure,omitempty"` +} + +type MCPExposureConfig struct { + Allowlist []string `yaml:"allowlist,omitempty"` + Denylist []string `yaml:"denylist,omitempty"` + Agents []MCPAgentExposureConfig `yaml:"agents,omitempty"` +} + +type MCPAgentExposureConfig struct { + Agent string `yaml:"agent"` + Allowlist []string `yaml:"allowlist,omitempty"` } type MCPServerConfig struct { @@ -466,16 +478,17 @@ func (c ToolsConfig) Validate() error { // Clone 返回 MCP 配置的独立副本,避免引用共享造成并发污染。 func (c MCPConfig) Clone() MCPConfig { + cloned := MCPConfig{ + Exposure: c.Exposure.Clone(), + } if len(c.Servers) == 0 { - return MCPConfig{} + return cloned } - cloned := make([]MCPServerConfig, 0, len(c.Servers)) + cloned.Servers = make([]MCPServerConfig, 0, len(c.Servers)) for _, server := range c.Servers { - cloned = append(cloned, server.Clone()) - } - return MCPConfig{ - Servers: cloned, + cloned.Servers = append(cloned.Servers, server.Clone()) } + return cloned } // Clone 返回单个 MCP server 配置的独立副本。 @@ -499,6 +512,7 @@ func (c *MCPConfig) ApplyDefaults(defaults MCPConfig) { if len(c.Servers) == 0 { c.Servers = defaults.Clone().Servers } + c.Exposure.ApplyDefaults(defaults.Exposure) for index := range c.Servers { c.Servers[index].ApplyDefaults() } @@ -506,6 +520,9 @@ func (c *MCPConfig) ApplyDefaults(defaults MCPConfig) { // Validate 校验 MCP server 列表与字段合法性,防止启动后失败。 func (c MCPConfig) Validate() error { + if err := c.Exposure.Validate(); err != nil { + return fmt.Errorf("exposure: %w", err) + } if len(c.Servers) == 0 { return nil } @@ -548,6 +565,80 @@ func (c MCPConfig) Validate() error { return nil } +// Clone 返回 MCP 工具暴露过滤配置的独立副本。 +func (c MCPExposureConfig) Clone() MCPExposureConfig { + cloned := MCPExposureConfig{ + Allowlist: append([]string(nil), c.Allowlist...), + Denylist: append([]string(nil), c.Denylist...), + } + if len(c.Agents) > 0 { + cloned.Agents = make([]MCPAgentExposureConfig, 0, len(c.Agents)) + for _, agent := range c.Agents { + cloned.Agents = append(cloned.Agents, agent.Clone()) + } + } + return cloned +} + +// Clone 返回单条 agent 暴露规则的独立副本。 +func (c MCPAgentExposureConfig) Clone() MCPAgentExposureConfig { + cloned := c + cloned.Allowlist = append([]string(nil), c.Allowlist...) + return cloned +} + +// ApplyDefaults 规范化 MCP 工具暴露过滤配置。 +func (c *MCPExposureConfig) ApplyDefaults(defaults MCPExposureConfig) { + if c == nil { + return + } + c.Allowlist = normalizePatternList(c.Allowlist) + c.Denylist = normalizePatternList(c.Denylist) + for index := range c.Agents { + c.Agents[index].ApplyDefaults() + } +} + +// ApplyDefaults 规范化单条 agent 暴露规则。 +func (c *MCPAgentExposureConfig) ApplyDefaults() { + if c == nil { + return + } + c.Agent = strings.TrimSpace(c.Agent) + c.Allowlist = normalizePatternList(c.Allowlist) +} + +// Validate 校验 MCP 暴露过滤配置合法性。 +func (c MCPExposureConfig) Validate() error { + seenAgents := make(map[string]struct{}, len(c.Agents)) + for index, agent := range c.Agents { + normalizedAgent := strings.ToLower(strings.TrimSpace(agent.Agent)) + if normalizedAgent == "" { + return fmt.Errorf("agents[%d].agent is empty", index) + } + if _, exists := seenAgents[normalizedAgent]; exists { + return fmt.Errorf("duplicate agents[%d].agent %q", index, agent.Agent) + } + seenAgents[normalizedAgent] = struct{}{} + for allowIndex, pattern := range agent.Allowlist { + if strings.TrimSpace(pattern) == "" { + return fmt.Errorf("agents[%d].allowlist[%d] is empty", index, allowIndex) + } + } + } + for index, pattern := range c.Allowlist { + if strings.TrimSpace(pattern) == "" { + return fmt.Errorf("allowlist[%d] is empty", index) + } + } + for index, pattern := range c.Denylist { + if strings.TrimSpace(pattern) == "" { + return fmt.Errorf("denylist[%d] is empty", index) + } + } + return nil +} + // Validate 校验上下文压缩配置是否合法。 func (c ContextConfig) Validate() error { if err := c.Compact.Validate(); err != nil { @@ -638,6 +729,25 @@ func (c *MCPServerConfig) ApplyDefaults() { } } +// normalizePatternList 规范化暴露过滤模式列表并剔除空项。 +func normalizePatternList(values []string) []string { + if len(values) == 0 { + return nil + } + result := make([]string, 0, len(values)) + for _, value := range values { + trimmed := strings.ToLower(strings.TrimSpace(value)) + if trimmed == "" { + continue + } + result = append(result, trimmed) + } + if len(result) == 0 { + return nil + } + return result +} + // ApplyDefaults 为 compact 配置填充缺省策略和阈值。 func (c *CompactConfig) ApplyDefaults(defaults CompactConfig) { if c == nil { diff --git a/internal/config/model_test.go b/internal/config/model_test.go index 4dc3ba3d..14956bdc 100644 --- a/internal/config/model_test.go +++ b/internal/config/model_test.go @@ -1067,3 +1067,139 @@ func TestConfigCloneNilReceiverReturnsDefaults(t *testing.T) { t.Fatal("expected cloned nil config to have default max_loops") } } + +func TestMCPExposureConfigCloneIndependence(t *testing.T) { + t.Parallel() + + original := MCPExposureConfig{ + Allowlist: []string{"docs"}, + Denylist: []string{"admin.secret"}, + Agents: []MCPAgentExposureConfig{ + {Agent: "coder", Allowlist: []string{"docs.search"}}, + }, + } + + cloned := original.Clone() + cloned.Allowlist[0] = "changed" + cloned.Denylist[0] = "changed" + cloned.Agents[0].Agent = "planner" + cloned.Agents[0].Allowlist[0] = "changed" + + if original.Allowlist[0] != "docs" || original.Denylist[0] != "admin.secret" { + t.Fatalf("expected top-level slices to remain unchanged, got %+v", original) + } + if original.Agents[0].Agent != "coder" || original.Agents[0].Allowlist[0] != "docs.search" { + t.Fatalf("expected agent clone independence, got %+v", original.Agents[0]) + } +} + +func TestMCPExposureConfigApplyDefaultsNormalizesValues(t *testing.T) { + t.Parallel() + + cfg := MCPExposureConfig{ + Allowlist: []string{" Docs ", "", "MCP.Search.Live"}, + Denylist: []string{" Admin.Secret "}, + Agents: []MCPAgentExposureConfig{ + {Agent: " Planner ", Allowlist: []string{" Docs.Search ", ""}}, + }, + } + + cfg.ApplyDefaults(MCPExposureConfig{}) + if strings.Join(cfg.Allowlist, ",") != "docs,mcp.search.live" { + t.Fatalf("unexpected allowlist normalization: %+v", cfg.Allowlist) + } + if strings.Join(cfg.Denylist, ",") != "admin.secret" { + t.Fatalf("unexpected denylist normalization: %+v", cfg.Denylist) + } + if cfg.Agents[0].Agent != "Planner" { + t.Fatalf("expected agent to keep trimmed original casing, got %q", cfg.Agents[0].Agent) + } + if strings.Join(cfg.Agents[0].Allowlist, ",") != "docs.search" { + t.Fatalf("unexpected agent allowlist normalization: %+v", cfg.Agents[0].Allowlist) + } +} + +func TestMCPExposureConfigValidateRejectsInvalidRules(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg MCPExposureConfig + want string + }{ + { + name: "empty agent", + cfg: MCPExposureConfig{ + Agents: []MCPAgentExposureConfig{{Agent: " ", Allowlist: []string{"docs"}}}, + }, + want: "agents[0].agent is empty", + }, + { + name: "duplicate agent", + cfg: MCPExposureConfig{ + Agents: []MCPAgentExposureConfig{ + {Agent: "coder", Allowlist: []string{"docs"}}, + {Agent: "CoDeR", Allowlist: []string{"search"}}, + }, + }, + want: "duplicate agents[1].agent", + }, + { + name: "empty allowlist item", + cfg: MCPExposureConfig{Allowlist: []string{"docs", " "}}, + want: "allowlist[1] is empty", + }, + { + name: "empty denylist item", + cfg: MCPExposureConfig{Denylist: []string{" "}}, + want: "denylist[0] is empty", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := tt.cfg.Validate() + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("expected %q, got %v", tt.want, err) + } + }) + } +} + +func TestMCPConfigClonePreservesExposureWithoutServers(t *testing.T) { + t.Parallel() + + cfg := MCPConfig{ + Exposure: MCPExposureConfig{ + Allowlist: []string{"docs"}, + Agents: []MCPAgentExposureConfig{ + {Agent: "coder", Allowlist: []string{"docs.search"}}, + }, + }, + } + + cloned := cfg.Clone() + if strings.Join(cloned.Exposure.Allowlist, ",") != "docs" { + t.Fatalf("expected exposure allowlist cloned, got %+v", cloned.Exposure) + } + if len(cloned.Servers) != 0 { + t.Fatalf("expected no servers, got %+v", cloned.Servers) + } +} + +func TestMCPConfigValidateWrapsExposureErrors(t *testing.T) { + t.Parallel() + + cfg := MCPConfig{ + Exposure: MCPExposureConfig{ + Allowlist: []string{" "}, + }, + } + + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "exposure: allowlist[0] is empty") { + t.Fatalf("expected wrapped exposure error, got %v", err) + } +} diff --git a/internal/tools/manager.go b/internal/tools/manager.go index dab53833..df0840bf 100644 --- a/internal/tools/manager.go +++ b/internal/tools/manager.go @@ -14,6 +14,7 @@ import ( type SpecListInput struct { SessionID string Agent string + Query string } // Manager is the runtime-facing tool execution and schema exposure boundary. diff --git a/internal/tools/mcp/adapter.go b/internal/tools/mcp/adapter.go index a0082f3f..a35fcbd3 100644 --- a/internal/tools/mcp/adapter.go +++ b/internal/tools/mcp/adapter.go @@ -29,6 +29,17 @@ func (f *AdapterFactory) BuildAdapters(ctx context.Context) ([]*Adapter, error) } snapshots := f.registry.Snapshot() + return f.BuildAdaptersFromSnapshots(ctx, snapshots) +} + +// BuildAdaptersFromSnapshots 将传入的不可变快照转换为 Adapter 列表。 +func (f *AdapterFactory) BuildAdaptersFromSnapshots(ctx context.Context, snapshots []ServerSnapshot) ([]*Adapter, error) { + if f == nil || f.registry == nil { + return nil, errors.New("mcp: adapter factory registry is nil") + } + if err := ctx.Err(); err != nil { + return nil, err + } if len(snapshots) == 0 { return nil, nil } diff --git a/internal/tools/mcp/adapter_test.go b/internal/tools/mcp/adapter_test.go index 20d8a381..ecf81921 100644 --- a/internal/tools/mcp/adapter_test.go +++ b/internal/tools/mcp/adapter_test.go @@ -52,6 +52,49 @@ func TestAdapterFactoryBuildAdaptersEmptySnapshot(t *testing.T) { } } +func TestAdapterFactoryBuildAdaptersFromSnapshots(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + if err := registry.RegisterServer("docs", "stdio", "v1", &stubServerClient{}); err != nil { + t.Fatalf("register server: %v", err) + } + + factory := NewAdapterFactory(registry) + snapshots := []ServerSnapshot{ + { + ServerID: "docs", + Status: ServerStatusReady, + Tools: []ToolDescriptor{ + {Name: "search", InputSchema: map[string]any{"type": "object"}}, + {Name: "lookup", InputSchema: map[string]any{"type": "object"}}, + }, + }, + } + adapters, err := factory.BuildAdaptersFromSnapshots(context.Background(), snapshots) + if err != nil { + t.Fatalf("BuildAdaptersFromSnapshots() error = %v", err) + } + if len(adapters) != 2 { + t.Fatalf("expected 2 adapters, got %d", len(adapters)) + } + if adapters[0].FullName() != "mcp.docs.search" || adapters[1].FullName() != "mcp.docs.lookup" { + t.Fatalf("unexpected adapter names: %q, %q", adapters[0].FullName(), adapters[1].FullName()) + } +} + +func TestAdapterFactoryBuildAdaptersFromSnapshotsCanceled(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + factory := NewAdapterFactory(NewRegistry()) + if _, err := factory.BuildAdaptersFromSnapshots(ctx, []ServerSnapshot{{ServerID: "docs"}}); err == nil { + t.Fatalf("expected canceled context error") + } +} + func TestAdapterCall(t *testing.T) { t.Parallel() diff --git a/internal/tools/mcp/exposure_filter.go b/internal/tools/mcp/exposure_filter.go new file mode 100644 index 00000000..98e2eaa6 --- /dev/null +++ b/internal/tools/mcp/exposure_filter.go @@ -0,0 +1,264 @@ +package mcp + +import ( + "context" + "fmt" + "path/filepath" + "sort" + "strings" +) + +// ExposureFilterReason 描述 MCP tool 暴露过滤命中的原因。 +type ExposureFilterReason string + +const ( + ExposureFilterReasonOffline ExposureFilterReason = "offline" + ExposureFilterReasonPolicyDeny ExposureFilterReason = "policy_deny" + ExposureFilterReasonAgentMismatch ExposureFilterReason = "agent_mismatch" + ExposureFilterReasonAllowlistMiss ExposureFilterReason = "allowlist_miss" + ExposureFilterReasonFilterError ExposureFilterReason = "filter_error" +) + +// ExposureFilterInput 描述一次 MCP specs 暴露过滤所依赖的上下文。 +type ExposureFilterInput struct { + SessionID string + Agent string + Query string +} + +// ExposureDecision 记录单个 MCP tool 是否暴露以及命中的过滤原因。 +type ExposureDecision struct { + ServerID string + ToolName string + ToolFullName string + Allowed bool + Reason ExposureFilterReason +} + +// ExposureFilter 定义 ListAvailableSpecs 阶段的 MCP 暴露过滤接口。 +type ExposureFilter interface { + Filter(ctx context.Context, snapshots []ServerSnapshot, input ExposureFilterInput) ([]ServerSnapshot, []ExposureDecision, error) +} + +// ExposureFilterConfig 描述 MCP tool 暴露过滤策略。 +type ExposureFilterConfig struct { + Allowlist []string + Denylist []string + Agents []AgentExposureRule +} + +// AgentExposureRule 描述单个 agent 角色可见的 MCP tool 模式集合。 +type AgentExposureRule struct { + Agent string + Allowlist []string +} + +// DefaultExposureFilter 是默认的 MCP 暴露过滤实现。 +type DefaultExposureFilter struct { + cfg ExposureFilterConfig +} + +// NewExposureFilter 创建默认 MCP 暴露过滤器。 +func NewExposureFilter(cfg ExposureFilterConfig) *DefaultExposureFilter { + return &DefaultExposureFilter{cfg: normalizeExposureFilterConfig(cfg)} +} + +// Filter 基于不可变快照执行 MCP tool 暴露过滤,并返回审计决策。 +func (f *DefaultExposureFilter) Filter( + ctx context.Context, + snapshots []ServerSnapshot, + input ExposureFilterInput, +) ([]ServerSnapshot, []ExposureDecision, error) { + if err := ctx.Err(); err != nil { + return nil, nil, err + } + + cfg := ExposureFilterConfig{} + if f != nil { + cfg = f.cfg + } + + normalizedAgent := strings.ToLower(strings.TrimSpace(input.Agent)) + var agentRule *AgentExposureRule + if len(cfg.Agents) > 0 { + for index := range cfg.Agents { + if cfg.Agents[index].Agent == normalizedAgent { + agentRule = &cfg.Agents[index] + break + } + } + } + + result := make([]ServerSnapshot, 0, len(snapshots)) + decisions := make([]ExposureDecision, 0) + for _, snapshot := range snapshots { + serverIDPattern := serverIdentity(snapshot.ServerID) + if snapshot.Status != ServerStatusReady { + for _, tool := range snapshot.Tools { + decisions = append(decisions, deniedExposureDecision(snapshot.ServerID, tool.Name, ExposureFilterReasonOffline)) + } + continue + } + + filteredTools := make([]ToolDescriptor, 0, len(snapshot.Tools)) + for _, tool := range snapshot.Tools { + fullName := composeToolName(snapshot.ServerID, tool.Name) + decision := ExposureDecision{ + ServerID: snapshot.ServerID, + ToolName: tool.Name, + ToolFullName: fullName, + } + + if matchesAnyIdentity(fullName, serverIDPattern, cfg.Denylist) { + decision.Reason = ExposureFilterReasonPolicyDeny + decisions = append(decisions, decision) + continue + } + if len(cfg.Allowlist) > 0 && !matchesAnyIdentity(fullName, serverIDPattern, cfg.Allowlist) { + decision.Reason = ExposureFilterReasonAllowlistMiss + decisions = append(decisions, decision) + continue + } + if len(cfg.Agents) > 0 && normalizedAgent != "" { + if agentRule == nil { + decision.Reason = ExposureFilterReasonAgentMismatch + decisions = append(decisions, decision) + continue + } + if len(agentRule.Allowlist) > 0 && !matchesAnyIdentity(fullName, serverIDPattern, agentRule.Allowlist) { + decision.Reason = ExposureFilterReasonAgentMismatch + decisions = append(decisions, decision) + continue + } + } + + decision.Allowed = true + filteredTools = append(filteredTools, tool) + decisions = append(decisions, decision) + } + + if len(filteredTools) == 0 { + continue + } + + cloned := snapshot + cloned.Tools = cloneToolDescriptors(filteredTools) + result = append(result, cloned) + } + + sort.SliceStable(decisions, func(i, j int) bool { + if decisions[i].ServerID == decisions[j].ServerID { + return decisions[i].ToolFullName < decisions[j].ToolFullName + } + return decisions[i].ServerID < decisions[j].ServerID + }) + return result, decisions, nil +} + +// deniedExposureDecision 构造一条拒绝暴露的审计记录。 +func deniedExposureDecision(serverID string, toolName string, reason ExposureFilterReason) ExposureDecision { + return ExposureDecision{ + ServerID: strings.TrimSpace(serverID), + ToolName: strings.TrimSpace(toolName), + ToolFullName: composeToolName(serverID, toolName), + Allowed: false, + Reason: reason, + } +} + +// normalizeExposureFilterConfig 规范化过滤配置,确保比较逻辑稳定一致。 +func normalizeExposureFilterConfig(cfg ExposureFilterConfig) ExposureFilterConfig { + cfg.Allowlist = normalizeExposurePatternList(cfg.Allowlist) + cfg.Denylist = normalizeExposurePatternList(cfg.Denylist) + if len(cfg.Agents) == 0 { + cfg.Agents = nil + return cfg + } + + agents := make([]AgentExposureRule, 0, len(cfg.Agents)) + for _, rule := range cfg.Agents { + normalizedAgent := strings.ToLower(strings.TrimSpace(rule.Agent)) + if normalizedAgent == "" { + continue + } + agents = append(agents, AgentExposureRule{ + Agent: normalizedAgent, + Allowlist: normalizeExposurePatternList(rule.Allowlist), + }) + } + if len(agents) == 0 { + cfg.Agents = nil + } else { + cfg.Agents = agents + } + return cfg +} + +// normalizeExposurePatternList 规范化模式列表并剔除空项。 +func normalizeExposurePatternList(values []string) []string { + if len(values) == 0 { + return nil + } + result := make([]string, 0, len(values)) + for _, value := range values { + normalized := normalizeExposurePattern(value) + if normalized == "" { + continue + } + result = append(result, normalized) + } + if len(result) == 0 { + return nil + } + return result +} + +// normalizeExposurePattern 将模式统一规范为 mcp 前缀的小写形式。 +func normalizeExposurePattern(value string) string { + normalized := strings.ToLower(strings.TrimSpace(value)) + if normalized == "" { + return "" + } + if strings.HasPrefix(normalized, "mcp.") { + return normalized + } + return "mcp." + normalized +} + +// matchesAnyIdentity 判断工具名或 server 名是否命中任一模式。 +func matchesAnyIdentity(fullName string, serverIdentity string, patterns []string) bool { + if len(patterns) == 0 { + return false + } + normalizedFullName := strings.ToLower(strings.TrimSpace(fullName)) + normalizedServer := strings.ToLower(strings.TrimSpace(serverIdentity)) + for _, pattern := range patterns { + if matchesExposurePattern(normalizedFullName, pattern) || matchesExposurePattern(normalizedServer, pattern) { + return true + } + } + return false +} + +// matchesExposurePattern 使用 glob 语义匹配单个暴露模式。 +func matchesExposurePattern(value string, pattern string) bool { + normalizedValue := strings.ToLower(strings.TrimSpace(value)) + normalizedPattern := normalizeExposurePattern(pattern) + if normalizedPattern == "" || normalizedValue == "" { + return false + } + matched, err := filepath.Match(normalizedPattern, normalizedValue) + if err == nil && matched { + return true + } + return normalizedValue == normalizedPattern +} + +// serverIdentity 返回 server 级别的匹配标识。 +func serverIdentity(serverID string) string { + server := strings.ToLower(strings.TrimSpace(serverID)) + if server == "" { + return "" + } + return fmt.Sprintf("mcp.%s", server) +} diff --git a/internal/tools/mcp/exposure_filter_test.go b/internal/tools/mcp/exposure_filter_test.go new file mode 100644 index 00000000..93dcf472 --- /dev/null +++ b/internal/tools/mcp/exposure_filter_test.go @@ -0,0 +1,183 @@ +package mcp + +import ( + "context" + "testing" +) + +func TestDefaultExposureFilterFiltersByStatusAndPolicy(t *testing.T) { + t.Parallel() + + filter := NewExposureFilter(ExposureFilterConfig{ + Allowlist: []string{"docs", "search.live"}, + Denylist: []string{"docs.secret"}, + }) + + snapshots := []ServerSnapshot{ + { + ServerID: "docs", + Status: ServerStatusReady, + Tools: []ToolDescriptor{ + {Name: "search"}, + {Name: "secret"}, + }, + }, + { + ServerID: "search", + Status: ServerStatusReady, + Tools: []ToolDescriptor{ + {Name: "live"}, + {Name: "private"}, + }, + }, + { + ServerID: "offline", + Status: ServerStatusOffline, + Tools: []ToolDescriptor{ + {Name: "down"}, + }, + }, + } + + filtered, decisions, err := filter.Filter(context.Background(), snapshots, ExposureFilterInput{Query: "golang"}) + if err != nil { + t.Fatalf("Filter() error = %v", err) + } + if len(filtered) != 2 { + t.Fatalf("expected 2 visible servers, got %d", len(filtered)) + } + if len(filtered[0].Tools) != 1 || filtered[0].Tools[0].Name != "search" { + t.Fatalf("expected docs.search only, got %+v", filtered[0].Tools) + } + if len(filtered[1].Tools) != 1 || filtered[1].Tools[0].Name != "live" { + t.Fatalf("expected search.live only, got %+v", filtered[1].Tools) + } + + got := map[string]ExposureFilterReason{} + for _, decision := range decisions { + got[decision.ToolFullName] = decision.Reason + if decision.ToolFullName == "mcp.docs.search" && !decision.Allowed { + t.Fatalf("expected docs.search allowed") + } + } + if got["mcp.docs.secret"] != ExposureFilterReasonPolicyDeny { + t.Fatalf("expected docs.secret denied by policy, got %q", got["mcp.docs.secret"]) + } + if got["mcp.search.private"] != ExposureFilterReasonAllowlistMiss { + t.Fatalf("expected search.private allowlist miss, got %q", got["mcp.search.private"]) + } + if got["mcp.offline.down"] != ExposureFilterReasonOffline { + t.Fatalf("expected offline.down offline, got %q", got["mcp.offline.down"]) + } +} + +func TestDefaultExposureFilterFiltersByAgentRule(t *testing.T) { + t.Parallel() + + filter := NewExposureFilter(ExposureFilterConfig{ + Agents: []AgentExposureRule{ + {Agent: "planner", Allowlist: []string{"docs"}}, + {Agent: "coder", Allowlist: []string{"docs.write"}}, + }, + }) + snapshots := []ServerSnapshot{ + { + ServerID: "docs", + Status: ServerStatusReady, + Tools: []ToolDescriptor{ + {Name: "read"}, + {Name: "write"}, + }, + }, + } + + plannerFiltered, plannerDecisions, err := filter.Filter(context.Background(), snapshots, ExposureFilterInput{Agent: "planner"}) + if err != nil { + t.Fatalf("planner Filter() error = %v", err) + } + if len(plannerFiltered) != 1 || len(plannerFiltered[0].Tools) != 2 { + t.Fatalf("expected planner to see whole docs server, got %+v", plannerFiltered) + } + + coderFiltered, coderDecisions, err := filter.Filter(context.Background(), snapshots, ExposureFilterInput{Agent: "CoDeR"}) + if err != nil { + t.Fatalf("coder Filter() error = %v", err) + } + if len(coderFiltered) != 1 || len(coderFiltered[0].Tools) != 1 || coderFiltered[0].Tools[0].Name != "write" { + t.Fatalf("expected coder to see docs.write only, got %+v", coderFiltered) + } + + unknownFiltered, unknownDecisions, err := filter.Filter(context.Background(), snapshots, ExposureFilterInput{Agent: "reviewer"}) + if err != nil { + t.Fatalf("unknown Filter() error = %v", err) + } + if len(unknownFiltered) != 0 { + t.Fatalf("expected unknown agent to see nothing, got %+v", unknownFiltered) + } + + if !hasDecisionReason(plannerDecisions, "mcp.docs.read", "") || !hasDecisionReason(plannerDecisions, "mcp.docs.write", "") { + t.Fatalf("expected planner decisions for both tools, got %+v", plannerDecisions) + } + if !hasDecisionReason(coderDecisions, "mcp.docs.read", ExposureFilterReasonAgentMismatch) { + t.Fatalf("expected coder read mismatch, got %+v", coderDecisions) + } + if !hasDecisionReason(unknownDecisions, "mcp.docs.write", ExposureFilterReasonAgentMismatch) { + t.Fatalf("expected unknown agent mismatch, got %+v", unknownDecisions) + } +} + +func TestDefaultExposureFilterSkipsAgentFilterWhenAgentIsEmpty(t *testing.T) { + t.Parallel() + + filter := NewExposureFilter(ExposureFilterConfig{ + Agents: []AgentExposureRule{ + {Agent: "planner", Allowlist: []string{"docs.read"}}, + }, + }) + snapshots := []ServerSnapshot{ + { + ServerID: "docs", + Status: ServerStatusReady, + Tools: []ToolDescriptor{ + {Name: "read"}, + {Name: "write"}, + }, + }, + } + + filtered, decisions, err := filter.Filter(context.Background(), snapshots, ExposureFilterInput{}) + if err != nil { + t.Fatalf("Filter() error = %v", err) + } + if len(filtered) != 1 || len(filtered[0].Tools) != 2 { + t.Fatalf("expected empty-agent input to keep tools visible, got %+v", filtered) + } + if !hasDecisionReason(decisions, "mcp.docs.read", "") || !hasDecisionReason(decisions, "mcp.docs.write", "") { + t.Fatalf("expected both tools allowed, got %+v", decisions) + } +} + +func TestDefaultExposureFilterHonorsContextCancellation(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + filter := NewExposureFilter(ExposureFilterConfig{}) + if _, _, err := filter.Filter(ctx, []ServerSnapshot{{ServerID: "docs"}}, ExposureFilterInput{}); err == nil { + t.Fatalf("expected canceled context error") + } +} + +func hasDecisionReason(decisions []ExposureDecision, fullName string, reason ExposureFilterReason) bool { + for _, decision := range decisions { + if decision.ToolFullName != fullName { + continue + } + if reason == "" { + return decision.Allowed + } + return !decision.Allowed && decision.Reason == reason + } + return false +} diff --git a/internal/tools/registry.go b/internal/tools/registry.go index 3f7af5a3..998ba939 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -16,6 +16,8 @@ type Registry struct { microCompactPolicies map[string]MicroCompactPolicy mcpRegistry *mcp.Registry mcpFactory *mcp.AdapterFactory + mcpExposureFilter mcp.ExposureFilter + mcpExposureAudit []mcp.ExposureDecision } func NewRegistry() *Registry { @@ -32,6 +34,27 @@ func (r *Registry) SetMCPRegistry(registry *mcp.Registry) { } r.mcpRegistry = registry r.mcpFactory = mcp.NewAdapterFactory(registry) + if r.mcpExposureFilter == nil { + r.mcpExposureFilter = mcp.NewExposureFilter(mcp.ExposureFilterConfig{}) + } +} + +// SetMCPExposureFilter 绑定 MCP 暴露过滤器,仅影响模型可见 specs,不影响工具执行。 +func (r *Registry) SetMCPExposureFilter(filter mcp.ExposureFilter) { + if r == nil { + return + } + r.mcpExposureFilter = filter +} + +// MCPExposureAuditSnapshot 返回最近一次 specs 过滤得到的 MCP 审计决策副本。 +func (r *Registry) MCPExposureAuditSnapshot() []mcp.ExposureDecision { + if r == nil || len(r.mcpExposureAudit) == 0 { + return nil + } + cloned := make([]mcp.ExposureDecision, len(r.mcpExposureAudit)) + copy(cloned, r.mcpExposureAudit) + return cloned } func (r *Registry) Register(tool Tool) { @@ -64,7 +87,7 @@ func (r *Registry) Supports(name string) bool { return r.supportsMCPTool(name) } -// MicroCompactPolicy 返回指定工具名的 micro compact 策略;未知工具按默认可压缩处理。 +// MicroCompactPolicy 返回指定工具的 micro compact 策略;未知工具按默认可压缩处理。 func (r *Registry) MicroCompactPolicy(name string) MicroCompactPolicy { if r == nil { return MicroCompactPolicyCompact @@ -102,14 +125,14 @@ func (r *Registry) ListSchemas() []providertypes.ToolSpec { return r.GetSpecs() } -// ListAvailableSpecs returns all registered tool specs. +// ListAvailableSpecs 返回当前上下文下模型可见的工具 specs。 func (r *Registry) ListAvailableSpecs(ctx context.Context, input SpecListInput) ([]providertypes.ToolSpec, error) { if err := ctx.Err(); err != nil { return nil, err } specs := r.GetSpecs() - mcpAdapters, err := r.listMCPAdapters(ctx) + mcpAdapters, err := r.listMCPAdapters(ctx, input) if err != nil { return nil, err } @@ -212,31 +235,128 @@ func (r *Registry) supportsMCPTool(name string) bool { } // listMCPAdapters 返回 MCP 快照对应的 adapter 列表。 -func (r *Registry) listMCPAdapters(ctx context.Context) ([]*mcp.Adapter, error) { - if r == nil || r.mcpFactory == nil { +func (r *Registry) listMCPAdapters(ctx context.Context, input SpecListInput) ([]*mcp.Adapter, error) { + if r == nil || r.mcpFactory == nil || r.mcpRegistry == nil { return nil, nil } - return r.mcpFactory.BuildAdapters(ctx) + snapshots := r.mcpRegistry.Snapshot() + filteredSnapshots, decisions, err := r.filterMCPSnapshots(ctx, snapshots, mcp.ExposureFilterInput{ + SessionID: input.SessionID, + Agent: input.Agent, + Query: input.Query, + }) + if err != nil { + return nil, err + } + r.mcpExposureAudit = decisions + return r.mcpFactory.BuildAdaptersFromSnapshots(ctx, filteredSnapshots) } // resolveMCPAdapter 按完整工具名解析并返回对应 adapter。 func (r *Registry) resolveMCPAdapter(ctx context.Context, fullName string) (*mcp.Adapter, error) { - adapters, err := r.listMCPAdapters(ctx) - if err != nil { + if err := ctx.Err(); err != nil { return nil, err } - lowerName := strings.ToLower(strings.TrimSpace(fullName)) - if !strings.HasPrefix(lowerName, "mcp.") { + if r == nil || r.mcpRegistry == nil { + return nil, errors.New("tool: not found") + } + + serverID, toolName, ok := parseMCPToolFullName(fullName) + if !ok { + return nil, errors.New("tool: not found") + } + + snapshots := r.mcpRegistry.Snapshot() + if r.isMCPToolPolicyDenied(ctx, snapshots, fullName) { return nil, errors.New("tool: not found") } - for _, adapter := range adapters { - if strings.EqualFold(adapter.FullName(), lowerName) { - return adapter, nil + + for _, snapshot := range snapshots { + if !strings.EqualFold(snapshot.ServerID, serverID) { + continue + } + for _, descriptor := range snapshot.Tools { + if strings.EqualFold(strings.TrimSpace(descriptor.Name), toolName) { + return mcp.NewAdapter(r.mcpRegistry, snapshot.ServerID, descriptor) + } } } return nil, errors.New("tool: not found") } +// filterMCPSnapshots 在暴露阶段过滤 MCP snapshots,并在过滤失败时按 fail-closed 退化。 +func (r *Registry) filterMCPSnapshots( + ctx context.Context, + snapshots []mcp.ServerSnapshot, + input mcp.ExposureFilterInput, +) ([]mcp.ServerSnapshot, []mcp.ExposureDecision, error) { + if err := ctx.Err(); err != nil { + return nil, nil, err + } + if r == nil { + return nil, nil, nil + } + filter := r.mcpExposureFilter + if filter == nil { + filter = mcp.NewExposureFilter(mcp.ExposureFilterConfig{}) + } + filteredSnapshots, decisions, err := filter.Filter(ctx, snapshots, input) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return nil, nil, err + } + failClosed := buildMCPFilterErrorAudit(snapshots) + r.mcpExposureAudit = failClosed + return nil, failClosed, nil + } + return filteredSnapshots, decisions, nil +} + +// isMCPToolPolicyDenied 判断指定 MCP 工具是否命中 policy_deny,命中则禁止执行。 +func (r *Registry) isMCPToolPolicyDenied(ctx context.Context, snapshots []mcp.ServerSnapshot, fullName string) bool { + if err := ctx.Err(); err != nil { + return false + } + if r == nil || len(snapshots) == 0 { + return false + } + filter := r.mcpExposureFilter + if filter == nil { + filter = mcp.NewExposureFilter(mcp.ExposureFilterConfig{}) + } + _, decisions, err := filter.Filter(ctx, snapshots, mcp.ExposureFilterInput{}) + if err != nil { + return false + } + normalized := strings.ToLower(strings.TrimSpace(fullName)) + for _, decision := range decisions { + if strings.EqualFold(decision.ToolFullName, normalized) && decision.Reason == mcp.ExposureFilterReasonPolicyDeny { + return true + } + } + return false +} + +// buildMCPFilterErrorAudit 为过滤器异常生成 fail-closed 审计记录。 +func buildMCPFilterErrorAudit(snapshots []mcp.ServerSnapshot) []mcp.ExposureDecision { + if len(snapshots) == 0 { + return nil + } + decisions := make([]mcp.ExposureDecision, 0) + for _, snapshot := range snapshots { + for _, tool := range snapshot.Tools { + decisions = append(decisions, mcp.ExposureDecision{ + ServerID: snapshot.ServerID, + ToolName: tool.Name, + ToolFullName: mcpToolFullName(snapshot.ServerID, tool.Name), + Allowed: false, + Reason: mcp.ExposureFilterReasonFilterError, + }) + } + } + return decisions +} + // mcpFactoryBuildSnapshot 读取 MCP registry 快照,用于无上下文快速检查。 func (r *Registry) mcpFactoryBuildSnapshot() []mcp.ServerSnapshot { if r == nil || r.mcpRegistry == nil { @@ -248,3 +368,16 @@ func (r *Registry) mcpFactoryBuildSnapshot() []mcp.ServerSnapshot { func mcpToolFullName(serverID string, toolName string) string { return "mcp." + strings.ToLower(strings.TrimSpace(serverID)) + "." + strings.ToLower(strings.TrimSpace(toolName)) } + +// parseMCPToolFullName 解析 mcp.. 形式的完整工具名。 +func parseMCPToolFullName(fullName string) (string, string, bool) { + normalized := strings.ToLower(strings.TrimSpace(fullName)) + if !strings.HasPrefix(normalized, "mcp.") { + return "", "", false + } + parts := strings.SplitN(normalized, ".", 3) + if len(parts) != 3 || strings.TrimSpace(parts[1]) == "" || strings.TrimSpace(parts[2]) == "" { + return "", "", false + } + return parts[1], parts[2], true +} diff --git a/internal/tools/registry_test.go b/internal/tools/registry_test.go index 09529b75..cc3a2028 100644 --- a/internal/tools/registry_test.go +++ b/internal/tools/registry_test.go @@ -250,6 +250,28 @@ type stubMCPClient struct { callErr error } +type stubExposureFilter struct { + filtered []mcp.ServerSnapshot + decisions []mcp.ExposureDecision + err error + inputs []mcp.ExposureFilterInput +} + +func (s *stubExposureFilter) Filter( + ctx context.Context, + snapshots []mcp.ServerSnapshot, + input mcp.ExposureFilterInput, +) ([]mcp.ServerSnapshot, []mcp.ExposureDecision, error) { + s.inputs = append(s.inputs, input) + if s.err != nil { + return nil, nil, s.err + } + if s.filtered != nil || s.decisions != nil { + return s.filtered, s.decisions, nil + } + return snapshots, nil, nil +} + func (s *stubMCPClient) ListTools(ctx context.Context) ([]mcp.ToolDescriptor, error) { return s.tools, nil } @@ -348,6 +370,39 @@ func TestRegistryExecuteDispatchesToMCPAdapter(t *testing.T) { } } +func TestRegistryExecuteRejectsPolicyDeniedMCPTool(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + mcpRegistry := mcp.NewRegistry() + if err := mcpRegistry.RegisterServer("docs", "stdio", "v1", &stubMCPClient{ + tools: []mcp.ToolDescriptor{ + {Name: "search", Description: "search docs", InputSchema: map[string]any{"type": "object"}}, + }, + callResult: mcp.CallResult{Content: "should not run"}, + }); err != nil { + t.Fatalf("register mcp server: %v", err) + } + if err := mcpRegistry.RefreshServerTools(context.Background(), "docs"); err != nil { + t.Fatalf("refresh mcp tools: %v", err) + } + registry.SetMCPRegistry(mcpRegistry) + registry.SetMCPExposureFilter(mcp.NewExposureFilter(mcp.ExposureFilterConfig{ + Denylist: []string{"docs.search"}, + })) + + result, err := registry.Execute(context.Background(), ToolCallInput{ + ID: "mcp-call-policy-deny", + Name: "mcp.docs.search", + }) + if err == nil || !strings.Contains(err.Error(), "tool: not found") { + t.Fatalf("expected tool not found for denied tool, got %v", err) + } + if !result.IsError { + t.Fatalf("expected IsError true") + } +} + func TestRegistryExecuteMCPResolveErrorPropagates(t *testing.T) { t.Parallel() @@ -520,3 +575,173 @@ func TestRegistrySupportsMCPToolAndHelpers(t *testing.T) { t.Fatalf("unexpected mcp full name: %q", got) } } + +func TestRegistryListAvailableSpecsAppliesMCPExposureFilter(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + registry.Register(stubTool{name: "a_tool", description: "built-in", schema: map[string]any{"type": "object"}}) + + mcpRegistry := mcp.NewRegistry() + if err := mcpRegistry.RegisterServer("docs", "stdio", "v1", &stubMCPClient{ + tools: []mcp.ToolDescriptor{ + {Name: "search", Description: "search docs", InputSchema: map[string]any{"type": "object"}}, + }, + }); err != nil { + t.Fatalf("register mcp server: %v", err) + } + if err := mcpRegistry.RegisterServer("admin", "stdio", "v1", &stubMCPClient{ + tools: []mcp.ToolDescriptor{ + {Name: "secret", Description: "secret tool", InputSchema: map[string]any{"type": "object"}}, + }, + }); err != nil { + t.Fatalf("register admin server: %v", err) + } + if err := mcpRegistry.RefreshServerTools(context.Background(), "docs"); err != nil { + t.Fatalf("refresh docs tools: %v", err) + } + if err := mcpRegistry.RefreshServerTools(context.Background(), "admin"); err != nil { + t.Fatalf("refresh admin tools: %v", err) + } + + registry.SetMCPRegistry(mcpRegistry) + registry.SetMCPExposureFilter(mcp.NewExposureFilter(mcp.ExposureFilterConfig{ + Allowlist: []string{"docs"}, + Denylist: []string{"admin.secret"}, + })) + + specs, err := registry.ListAvailableSpecs(context.Background(), SpecListInput{Agent: "coder", SessionID: "s-1"}) + if err != nil { + t.Fatalf("ListAvailableSpecs() error = %v", err) + } + + names := make([]string, 0, len(specs)) + for _, spec := range specs { + names = append(names, spec.Name) + } + if strings.Join(names, ",") != "a_tool,mcp.docs.search" { + t.Fatalf("unexpected specs: %+v", names) + } + + audit := registry.MCPExposureAuditSnapshot() + if len(audit) != 2 { + t.Fatalf("expected 2 audit decisions, got %+v", audit) + } + if audit[0].ToolFullName != "mcp.admin.secret" || audit[0].Reason != mcp.ExposureFilterReasonPolicyDeny { + t.Fatalf("unexpected audit[0]: %+v", audit[0]) + } +} + +func TestRegistryListAvailableSpecsFailClosedOnExposureFilterError(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + registry.Register(stubTool{name: "builtin", description: "built-in", schema: map[string]any{"type": "object"}}) + + mcpRegistry := mcp.NewRegistry() + if err := mcpRegistry.RegisterServer("docs", "stdio", "v1", &stubMCPClient{ + tools: []mcp.ToolDescriptor{ + {Name: "search", Description: "search docs", InputSchema: map[string]any{"type": "object"}}, + }, + }); err != nil { + t.Fatalf("register mcp server: %v", err) + } + if err := mcpRegistry.RefreshServerTools(context.Background(), "docs"); err != nil { + t.Fatalf("refresh mcp tools: %v", err) + } + + registry.SetMCPRegistry(mcpRegistry) + registry.SetMCPExposureFilter(&stubExposureFilter{err: errors.New("boom")}) + + specs, err := registry.ListAvailableSpecs(context.Background(), SpecListInput{}) + if err != nil { + t.Fatalf("ListAvailableSpecs() error = %v", err) + } + if len(specs) != 1 || specs[0].Name != "builtin" { + t.Fatalf("expected built-in specs only, got %+v", specs) + } + + audit := registry.MCPExposureAuditSnapshot() + if len(audit) != 1 || audit[0].Reason != mcp.ExposureFilterReasonFilterError { + t.Fatalf("expected filter_error audit, got %+v", audit) + } +} + +func TestRegistryListAvailableSpecsReturnsContextErrorFromExposureFilter(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + registry.Register(stubTool{name: "builtin", description: "built-in", schema: map[string]any{"type": "object"}}) + + mcpRegistry := mcp.NewRegistry() + if err := mcpRegistry.RegisterServer("docs", "stdio", "v1", &stubMCPClient{ + tools: []mcp.ToolDescriptor{ + {Name: "search", Description: "search docs", InputSchema: map[string]any{"type": "object"}}, + }, + }); err != nil { + t.Fatalf("register mcp server: %v", err) + } + if err := mcpRegistry.RefreshServerTools(context.Background(), "docs"); err != nil { + t.Fatalf("refresh mcp tools: %v", err) + } + + registry.SetMCPRegistry(mcpRegistry) + registry.SetMCPExposureFilter(&stubExposureFilter{err: context.Canceled}) + + _, err := registry.ListAvailableSpecs(context.Background(), SpecListInput{}) + if err == nil || !errors.Is(err, context.Canceled) { + t.Fatalf("expected context canceled, got %v", err) + } +} + +func TestRegistryListAvailableSpecsPassesAgentAndQueryToExposureFilter(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + mcpRegistry := mcp.NewRegistry() + if err := mcpRegistry.RegisterServer("docs", "stdio", "v1", &stubMCPClient{ + tools: []mcp.ToolDescriptor{ + {Name: "search", Description: "search docs", InputSchema: map[string]any{"type": "object"}}, + }, + }); err != nil { + t.Fatalf("register mcp server: %v", err) + } + if err := mcpRegistry.RefreshServerTools(context.Background(), "docs"); err != nil { + t.Fatalf("refresh mcp tools: %v", err) + } + + filter := &stubExposureFilter{} + registry.SetMCPRegistry(mcpRegistry) + registry.SetMCPExposureFilter(filter) + + _, err := registry.ListAvailableSpecs(context.Background(), SpecListInput{ + SessionID: "session-1", + Agent: "planner", + Query: "find docs", + }) + if err != nil { + t.Fatalf("ListAvailableSpecs() error = %v", err) + } + if len(filter.inputs) != 1 { + t.Fatalf("expected one filter input, got %+v", filter.inputs) + } + if filter.inputs[0].SessionID != "session-1" || filter.inputs[0].Agent != "planner" || filter.inputs[0].Query != "find docs" { + t.Fatalf("unexpected filter input: %+v", filter.inputs[0]) + } +} + +func TestParseMCPToolFullName(t *testing.T) { + t.Parallel() + + serverID, toolName, ok := parseMCPToolFullName(" MCP.Docs.Search ") + if !ok { + t.Fatalf("expected parse success") + } + if serverID != "docs" || toolName != "search" { + t.Fatalf("unexpected parse result: server=%q tool=%q", serverID, toolName) + } + + if _, _, ok := parseMCPToolFullName("mcp.docs"); ok { + t.Fatalf("expected parse failure for incomplete name") + } +}