Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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 {
Expand Down
69 changes: 69 additions & 0 deletions internal/app/bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
124 changes: 117 additions & 7 deletions internal/config/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 配置的独立副本。
Expand All @@ -499,13 +512,17 @@ 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()
}
}

// 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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading