From d3069a3a0e2611042b1793435e06d1b06eaef5ad Mon Sep 17 00:00:00 2001 From: Nicolas Laurance Date: Thu, 14 May 2026 13:21:23 +0100 Subject: [PATCH 01/11] feat(config): add enabled_tools/disabled_tools per-server allowlist/denylist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two mutually exclusive fields to ServerConfig that let operators declare tool visibility statically in mcp_config.json rather than having to call the API or CLI after every fresh install. enabled_tools: ["list_issues", "get_issue"] // allowlist — only these visible disabled_tools: ["delete_repo", "force_push"] // denylist — hide these, allow rest Config validation rejects a server that has both fields set. On every applyDifferentialToolUpdate (server connect / tool refresh), applyConfigToolFilter walks the in-memory config, computes the desired enabled/disabled state for each discovered tool, and calls setToolEnabledNoEmit to persist it in BBolt. All existing enforcement paths (isToolCallable, retrieve_tools pre-ranking, call_tool_*) pick up the change automatically with no further modifications. Five unit tests cover: allowlist disables unlisted tools, allowlist re-enables a tool moved back into the list, denylist disables listed tools, no-op when neither field is set, and end-to-end integration through applyDifferentialToolUpdate. Co-Authored-By: Claude Sonnet 4.6 --- internal/config/config.go | 12 +- internal/config/validation_test.go | 58 +++++++ internal/runtime/lifecycle.go | 7 + internal/runtime/tool_config_filter_test.go | 177 ++++++++++++++++++++ internal/runtime/tool_quarantine.go | 69 ++++++++ 5 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 internal/runtime/tool_config_filter_test.go diff --git a/internal/config/config.go b/internal/config/config.go index 212c9200..9b29bea0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -239,7 +239,9 @@ type ServerConfig struct { // when the server is configured with both Command and an HTTP/SSE URL — i.e., // mcpproxy starts the process AND connects via network. Stdio servers ignore // this field. Zero or unset → 30s default. - LauncherWaitTimeout Duration `json:"launcher_wait_timeout,omitempty" mapstructure:"launcher_wait_timeout" swaggertype:"string"` + LauncherWaitTimeout Duration `json:"launcher_wait_timeout,omitempty" mapstructure:"launcher_wait_timeout" swaggertype:"string"` + EnabledTools []string `json:"enabled_tools,omitempty" mapstructure:"enabled_tools"` // Allowlist: only these tools are exposed; mutually exclusive with disabled_tools + DisabledTools []string `json:"disabled_tools,omitempty" mapstructure:"disabled_tools"` // Denylist: these tools are hidden; mutually exclusive with enabled_tools } // OAuthConfig represents OAuth configuration for a server @@ -1009,6 +1011,14 @@ func (c *Config) ValidateDetailed() []ValidationError { // Note: OAuth configuration is optional. client_id is optional (uses Dynamic Client Registration RFC 7591 if empty). // ClientSecret can be a secret reference, so we don't validate it as empty. + + // enabled_tools and disabled_tools are mutually exclusive + if len(server.EnabledTools) > 0 && len(server.DisabledTools) > 0 { + errors = append(errors, ValidationError{ + Field: fieldPrefix + ".enabled_tools", + Message: "enabled_tools and disabled_tools are mutually exclusive; use one or the other", + }) + } } // Validate DataDir exists (if specified and not empty). diff --git a/internal/config/validation_test.go b/internal/config/validation_test.go index b3893abc..caaf83bf 100644 --- a/internal/config/validation_test.go +++ b/internal/config/validation_test.go @@ -236,6 +236,64 @@ func TestValidateDetailed(t *testing.T) { expectedErrors: 0, errorFields: []string{}, }, + { + name: "enabled_tools and disabled_tools are mutually exclusive", + config: &Config{ + Listen: ":8080", + ToolsLimit: 15, + ToolResponseLimit: 1000, + CallToolTimeout: Duration(60000000000), + Servers: []*ServerConfig{ + { + Name: "test", + Protocol: "http", + URL: "https://api.example.com/mcp", + EnabledTools: []string{"read_file"}, + DisabledTools: []string{"write_file"}, + }, + }, + }, + expectedErrors: 1, + errorFields: []string{"mcpServers[0].enabled_tools"}, + }, + { + name: "enabled_tools alone is valid", + config: &Config{ + Listen: ":8080", + ToolsLimit: 15, + ToolResponseLimit: 1000, + CallToolTimeout: Duration(60000000000), + Servers: []*ServerConfig{ + { + Name: "test", + Protocol: "http", + URL: "https://api.example.com/mcp", + EnabledTools: []string{"read_file", "list_dir"}, + }, + }, + }, + expectedErrors: 0, + errorFields: []string{}, + }, + { + name: "disabled_tools alone is valid", + config: &Config{ + Listen: ":8080", + ToolsLimit: 15, + ToolResponseLimit: 1000, + CallToolTimeout: Duration(60000000000), + Servers: []*ServerConfig{ + { + Name: "test", + Protocol: "http", + URL: "https://api.example.com/mcp", + DisabledTools: []string{"delete_file", "execute_code"}, + }, + }, + }, + expectedErrors: 0, + errorFields: []string{}, + }, } for _, tt := range tests { diff --git a/internal/runtime/lifecycle.go b/internal/runtime/lifecycle.go index 3270295c..9284e830 100644 --- a/internal/runtime/lifecycle.go +++ b/internal/runtime/lifecycle.go @@ -497,6 +497,13 @@ func (r *Runtime) applyDifferentialToolUpdate(ctx context.Context, serverName st approvalResult = &ToolApprovalResult{BlockedTools: make(map[string]bool)} } + // Sync enabled_tools / disabled_tools from server config into BBolt + if err := r.applyConfigToolFilter(serverName, newTools); err != nil { + r.logger.Warn("Failed to apply config tool filter", + zap.String("server", serverName), + zap.Error(err)) + } + // Query existing tools from the index existingTools, err := r.indexManager.GetToolsByServer(serverName) if err != nil { diff --git a/internal/runtime/tool_config_filter_test.go b/internal/runtime/tool_config_filter_test.go new file mode 100644 index 00000000..ebc41b66 --- /dev/null +++ b/internal/runtime/tool_config_filter_test.go @@ -0,0 +1,177 @@ +package runtime + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" + "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" +) + +func setupConfigFilterRuntime(t *testing.T, servers []*config.ServerConfig) *Runtime { + t.Helper() + tempDir := t.TempDir() + cfg := &config.Config{ + DataDir: tempDir, + Listen: "127.0.0.1:0", + Servers: servers, + } + rt, err := New(cfg, "", zap.NewNop()) + require.NoError(t, err) + t.Cleanup(func() { _ = rt.Close() }) + return rt +} + +// TestApplyConfigToolFilter_EnabledTools_DisablesNonListedTools verifies that +// when a server has enabled_tools set, tools not in that list are disabled in +// BBolt so they are hidden from MCP clients. +func TestApplyConfigToolFilter_EnabledTools_DisablesNonListedTools(t *testing.T) { + rt := setupConfigFilterRuntime(t, []*config.ServerConfig{ + { + Name: "github", + Enabled: true, + EnabledTools: []string{"list_issues", "get_issue"}, + }, + }) + + tools := []*config.ToolMetadata{ + {ServerName: "github", Name: "list_issues", Description: "List issues", ParamsJSON: `{}`}, + {ServerName: "github", Name: "get_issue", Description: "Get issue", ParamsJSON: `{}`}, + {ServerName: "github", Name: "create_issue", Description: "Create issue", ParamsJSON: `{}`}, + {ServerName: "github", Name: "delete_issue", Description: "Delete issue", ParamsJSON: `{}`}, + } + + err := rt.applyConfigToolFilter("github", tools) + require.NoError(t, err) + + // Allowed tools should remain enabled (no record or Disabled=false) + for _, allowed := range []string{"list_issues", "get_issue"} { + record, err := rt.storageManager.GetToolApproval("github", allowed) + if err == nil && record != nil { + assert.False(t, record.Disabled, "tool %q should be enabled", allowed) + } + // ErrToolApprovalNotFound is also acceptable (means enabled by default) + } + + // Non-listed tools must be explicitly disabled + for _, blocked := range []string{"create_issue", "delete_issue"} { + record, err := rt.storageManager.GetToolApproval("github", blocked) + require.NoError(t, err, "expected approval record for %q", blocked) + assert.True(t, record.Disabled, "tool %q should be disabled", blocked) + } +} + +// TestApplyConfigToolFilter_DisabledTools_DisablesListedTools verifies that +// when a server has disabled_tools set, only those specific tools are disabled. +func TestApplyConfigToolFilter_DisabledTools_DisablesListedTools(t *testing.T) { + rt := setupConfigFilterRuntime(t, []*config.ServerConfig{ + { + Name: "github", + Enabled: true, + DisabledTools: []string{"delete_repo", "force_push"}, + }, + }) + + tools := []*config.ToolMetadata{ + {ServerName: "github", Name: "list_repos", Description: "List repos", ParamsJSON: `{}`}, + {ServerName: "github", Name: "delete_repo", Description: "Delete repo", ParamsJSON: `{}`}, + {ServerName: "github", Name: "force_push", Description: "Force push", ParamsJSON: `{}`}, + } + + err := rt.applyConfigToolFilter("github", tools) + require.NoError(t, err) + + // Listed tools must be disabled + for _, blocked := range []string{"delete_repo", "force_push"} { + record, err := rt.storageManager.GetToolApproval("github", blocked) + require.NoError(t, err, "expected approval record for %q", blocked) + assert.True(t, record.Disabled, "tool %q should be disabled", blocked) + } + + // Non-listed tools should remain enabled + record, err := rt.storageManager.GetToolApproval("github", "list_repos") + if err == nil && record != nil { + assert.False(t, record.Disabled, "tool %q should be enabled", "list_repos") + } +} + +// TestApplyConfigToolFilter_NoFilter_NoChanges verifies that when neither +// enabled_tools nor disabled_tools is set, no records are written. +func TestApplyConfigToolFilter_NoFilter_NoChanges(t *testing.T) { + rt := setupConfigFilterRuntime(t, []*config.ServerConfig{ + {Name: "github", Enabled: true}, + }) + + tools := []*config.ToolMetadata{ + {ServerName: "github", Name: "list_issues", Description: "List issues", ParamsJSON: `{}`}, + } + + err := rt.applyConfigToolFilter("github", tools) + require.NoError(t, err) + + // No approval record should have been written + _, err = rt.storageManager.GetToolApproval("github", "list_issues") + assert.ErrorIs(t, err, storage.ErrToolApprovalNotFound) +} + +// TestApplyConfigToolFilter_EnabledTools_ReEnablesTool verifies that a tool +// previously disabled (e.g. by the API) is re-enabled if it appears in +// enabled_tools on the next applyConfigToolFilter call. +func TestApplyConfigToolFilter_EnabledTools_ReEnablesTool(t *testing.T) { + rt := setupConfigFilterRuntime(t, []*config.ServerConfig{ + { + Name: "github", + Enabled: true, + EnabledTools: []string{"list_issues", "get_issue"}, + }, + }) + + // Manually mark get_issue as disabled (simulating a prior API call) + require.NoError(t, rt.storageManager.SaveToolApproval(&storage.ToolApprovalRecord{ + ServerName: "github", + ToolName: "get_issue", + Status: storage.ToolApprovalStatusApproved, + Disabled: true, + })) + + tools := []*config.ToolMetadata{ + {ServerName: "github", Name: "list_issues", Description: "List issues", ParamsJSON: `{}`}, + {ServerName: "github", Name: "get_issue", Description: "Get issue", ParamsJSON: `{}`}, + } + + err := rt.applyConfigToolFilter("github", tools) + require.NoError(t, err) + + // get_issue is in the enabled_tools list — must be re-enabled + record, err := rt.storageManager.GetToolApproval("github", "get_issue") + require.NoError(t, err) + assert.False(t, record.Disabled, "get_issue should be re-enabled by config") +} + +// TestApplyDifferentialToolUpdate_RespectsEnabledToolsConfig is an integration +// test verifying that applyDifferentialToolUpdate honours enabled_tools from the +// server config: tools not in the list end up with Disabled=true in storage. +func TestApplyDifferentialToolUpdate_RespectsEnabledToolsConfig(t *testing.T) { + rt := setupConfigFilterRuntime(t, []*config.ServerConfig{ + { + Name: "github", + Enabled: true, + EnabledTools: []string{"list_issues"}, + }, + }) + + tools := []*config.ToolMetadata{ + {ServerName: "github", Name: "list_issues", Description: "List issues", ParamsJSON: `{}`}, + {ServerName: "github", Name: "create_issue", Description: "Create issue", ParamsJSON: `{}`}, + } + + err := rt.applyDifferentialToolUpdate(t.Context(), "github", tools) + require.NoError(t, err) + + blocked, err := rt.storageManager.GetToolApproval("github", "create_issue") + require.NoError(t, err) + assert.True(t, blocked.Disabled, "create_issue should be disabled by enabled_tools config") +} \ No newline at end of file diff --git a/internal/runtime/tool_quarantine.go b/internal/runtime/tool_quarantine.go index e159ad0d..a4ff96ef 100644 --- a/internal/runtime/tool_quarantine.go +++ b/internal/runtime/tool_quarantine.go @@ -921,3 +921,72 @@ func (r *Runtime) emitToolQuarantineEvent(serverName, toolName, action, oldHash, } r.publishEvent(newEvent(EventTypeActivityToolQuarantineChange, payload)) } + +// applyConfigToolFilter syncs the enabled_tools / disabled_tools lists from +// the server's static config into BBolt ToolApprovalRecord.Disabled flags. +// It is called from applyDifferentialToolUpdate so the config-declared filter +// is enforced on every connect / tool-refresh cycle. +// +// - enabled_tools (allowlist): every tool NOT in the list is disabled. +// - disabled_tools (denylist): every tool IN the list is disabled; others +// are (re-)enabled so a tool removed from the denylist becomes visible. +// +// When neither field is set the function is a no-op — no records are written. +func (r *Runtime) applyConfigToolFilter(serverName string, tools []*config.ToolMetadata) error { + if r.storageManager == nil { + return nil + } + + // Read from the in-memory config snapshot (EnabledTools/DisabledTools are + // static declarations, not runtime state — no BBolt read needed). + var serverCfg *config.ServerConfig + for _, sc := range r.Config().Servers { + if sc.Name == serverName { + serverCfg = sc + break + } + } + if serverCfg == nil { + return nil + } + + hasAllowList := len(serverCfg.EnabledTools) > 0 + hasDenyList := len(serverCfg.DisabledTools) > 0 + + if !hasAllowList && !hasDenyList { + return nil + } + + allowSet := make(map[string]bool, len(serverCfg.EnabledTools)) + for _, t := range serverCfg.EnabledTools { + allowSet[t] = true + } + denySet := make(map[string]bool, len(serverCfg.DisabledTools)) + for _, t := range serverCfg.DisabledTools { + denySet[t] = true + } + + for _, tool := range tools { + toolName := tool.Name + if idx := strings.Index(toolName, ":"); idx != -1 { + toolName = toolName[idx+1:] + } + + var shouldEnable bool + if hasAllowList { + shouldEnable = allowSet[toolName] + } else { + shouldEnable = !denySet[toolName] + } + + if _, err := r.setToolEnabledNoEmit(serverName, toolName, shouldEnable, "config"); err != nil { + r.logger.Warn("applyConfigToolFilter: failed to set tool enabled state", + zap.String("server", serverName), + zap.String("tool", toolName), + zap.Bool("enabled", shouldEnable), + zap.Error(err)) + } + } + + return nil +} From d5dbbcfcb0aa1c74643b10cec09355e1f231ebe0 Mon Sep 17 00:00:00 2001 From: Nicolas Laurance Date: Fri, 15 May 2026 16:21:35 +0100 Subject: [PATCH 02/11] feat(config): add IsToolAllowedByConfig helper on ServerConfig --- internal/config/config.go | 19 +++++++++++++++++++ internal/config/config_test.go | 23 +++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index 9b29bea0..00e88e08 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -842,6 +842,25 @@ func (sc *ServerConfig) IsQuarantineSkipped() bool { return sc.SkipQuarantine } +// IsToolAllowedByConfig reports whether toolName passes the server's static +// enabled_tools / disabled_tools filter. Returns true when neither list is set. +func (sc *ServerConfig) IsToolAllowedByConfig(toolName string) bool { + if len(sc.EnabledTools) > 0 { + for _, t := range sc.EnabledTools { + if t == toolName { + return true + } + } + return false + } + for _, t := range sc.DisabledTools { + if t == toolName { + return false + } + } + return true +} + // EnsureAPIKey ensures the API key is set, generating one if needed // Returns the API key, whether it was auto-generated, and the source // SECURITY: Empty API keys are never allowed - always auto-generates if empty or missing diff --git a/internal/config/config_test.go b/internal/config/config_test.go index bd74282c..c6888d92 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1356,3 +1356,26 @@ func TestServerConfig_ReconnectOnUse(t *testing.T) { assert.Equal(t, server.ReconnectOnUse, restored.ReconnectOnUse) }) } + +func TestServerConfig_IsToolAllowedByConfig(t *testing.T) { + tests := []struct { + name string + cfg *ServerConfig + toolName string + want bool + }{ + {"no filter allows everything", &ServerConfig{}, "anything", true}, + {"allowlist: listed tool allowed", &ServerConfig{EnabledTools: []string{"read_file", "list_dir"}}, "read_file", true}, + {"allowlist: unlisted tool denied", &ServerConfig{EnabledTools: []string{"read_file"}}, "delete_file", false}, + {"denylist: listed tool denied", &ServerConfig{DisabledTools: []string{"delete_repo"}}, "delete_repo", false}, + {"denylist: unlisted tool allowed", &ServerConfig{DisabledTools: []string{"delete_repo"}}, "list_repos", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.cfg.IsToolAllowedByConfig(tt.toolName) + if got != tt.want { + t.Errorf("IsToolAllowedByConfig(%q) = %v, want %v", tt.toolName, got, tt.want) + } + }) + } +} From e41a9e147c456c7f9fbdd5e943a104f41e623a68 Mon Sep 17 00:00:00 2001 From: Nicolas Laurance Date: Fri, 15 May 2026 16:34:21 +0100 Subject: [PATCH 03/11] feat(runtime): replace BBolt-writing applyConfigToolFilter with IsToolConfigDenied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the BBolt-writing applyConfigToolFilter function (which overwrote user preferences on every reconnect, emitted spurious audit events, and had no provenance) with an evaluation-time IsToolConfigDenied method that: - Reads config at call time, never writes to BBolt - Preserves user-set tool preferences and audit trail - Enables separation of concerns: config vs user intent Key changes: - Delete applyConfigToolFilter from tool_quarantine.go (63 lines removed) - Add IsToolConfigDenied(serverName, toolName string) bool to Runtime - Remove applyConfigToolFilter call from lifecycle.go - Rewrite tool_config_filter_test.go: 5 new tests for IsToolConfigDenied * AllowList: tools not listed are denied * DenyList: listed tools are denied * NoFilter: all tools allowed when config has no filter * UnknownServer: returns false for missing servers * UserDisabledPreserved: BBolt state is independent from config layer All 198 runtime tests pass. No behavior change to actual tool visibility— the config layer is now just evaluated at call time instead of persisted. Co-Authored-By: Claude Sonnet 4.6 --- internal/runtime/lifecycle.go | 7 - internal/runtime/tool_config_filter_test.go | 151 ++++---------------- internal/runtime/tool_quarantine.go | 70 +-------- 3 files changed, 32 insertions(+), 196 deletions(-) diff --git a/internal/runtime/lifecycle.go b/internal/runtime/lifecycle.go index 9284e830..3270295c 100644 --- a/internal/runtime/lifecycle.go +++ b/internal/runtime/lifecycle.go @@ -497,13 +497,6 @@ func (r *Runtime) applyDifferentialToolUpdate(ctx context.Context, serverName st approvalResult = &ToolApprovalResult{BlockedTools: make(map[string]bool)} } - // Sync enabled_tools / disabled_tools from server config into BBolt - if err := r.applyConfigToolFilter(serverName, newTools); err != nil { - r.logger.Warn("Failed to apply config tool filter", - zap.String("server", serverName), - zap.Error(err)) - } - // Query existing tools from the index existingTools, err := r.indexManager.GetToolsByServer(serverName) if err != nil { diff --git a/internal/runtime/tool_config_filter_test.go b/internal/runtime/tool_config_filter_test.go index ebc41b66..31184d01 100644 --- a/internal/runtime/tool_config_filter_test.go +++ b/internal/runtime/tool_config_filter_test.go @@ -8,7 +8,6 @@ import ( "go.uber.org/zap" "github.com/smart-mcp-proxy/mcpproxy-go/internal/config" - "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage" ) func setupConfigFilterRuntime(t *testing.T, servers []*config.ServerConfig) *Runtime { @@ -25,153 +24,53 @@ func setupConfigFilterRuntime(t *testing.T, servers []*config.ServerConfig) *Run return rt } -// TestApplyConfigToolFilter_EnabledTools_DisablesNonListedTools verifies that -// when a server has enabled_tools set, tools not in that list are disabled in -// BBolt so they are hidden from MCP clients. -func TestApplyConfigToolFilter_EnabledTools_DisablesNonListedTools(t *testing.T) { +func TestIsToolConfigDenied_AllowList(t *testing.T) { rt := setupConfigFilterRuntime(t, []*config.ServerConfig{ - { - Name: "github", - Enabled: true, - EnabledTools: []string{"list_issues", "get_issue"}, - }, + {Name: "github", Enabled: true, EnabledTools: []string{"list_issues", "get_issue"}}, }) - tools := []*config.ToolMetadata{ - {ServerName: "github", Name: "list_issues", Description: "List issues", ParamsJSON: `{}`}, - {ServerName: "github", Name: "get_issue", Description: "Get issue", ParamsJSON: `{}`}, - {ServerName: "github", Name: "create_issue", Description: "Create issue", ParamsJSON: `{}`}, - {ServerName: "github", Name: "delete_issue", Description: "Delete issue", ParamsJSON: `{}`}, - } - - err := rt.applyConfigToolFilter("github", tools) - require.NoError(t, err) - - // Allowed tools should remain enabled (no record or Disabled=false) - for _, allowed := range []string{"list_issues", "get_issue"} { - record, err := rt.storageManager.GetToolApproval("github", allowed) - if err == nil && record != nil { - assert.False(t, record.Disabled, "tool %q should be enabled", allowed) - } - // ErrToolApprovalNotFound is also acceptable (means enabled by default) - } - - // Non-listed tools must be explicitly disabled - for _, blocked := range []string{"create_issue", "delete_issue"} { - record, err := rt.storageManager.GetToolApproval("github", blocked) - require.NoError(t, err, "expected approval record for %q", blocked) - assert.True(t, record.Disabled, "tool %q should be disabled", blocked) - } + assert.False(t, rt.IsToolConfigDenied("github", "list_issues"), "list_issues should be allowed") + assert.False(t, rt.IsToolConfigDenied("github", "get_issue"), "get_issue should be allowed") + assert.True(t, rt.IsToolConfigDenied("github", "create_issue"), "create_issue not in allowlist → denied") + assert.True(t, rt.IsToolConfigDenied("github", "delete_issue"), "delete_issue not in allowlist → denied") } -// TestApplyConfigToolFilter_DisabledTools_DisablesListedTools verifies that -// when a server has disabled_tools set, only those specific tools are disabled. -func TestApplyConfigToolFilter_DisabledTools_DisablesListedTools(t *testing.T) { +func TestIsToolConfigDenied_DenyList(t *testing.T) { rt := setupConfigFilterRuntime(t, []*config.ServerConfig{ - { - Name: "github", - Enabled: true, - DisabledTools: []string{"delete_repo", "force_push"}, - }, + {Name: "github", Enabled: true, DisabledTools: []string{"delete_repo", "force_push"}}, }) - tools := []*config.ToolMetadata{ - {ServerName: "github", Name: "list_repos", Description: "List repos", ParamsJSON: `{}`}, - {ServerName: "github", Name: "delete_repo", Description: "Delete repo", ParamsJSON: `{}`}, - {ServerName: "github", Name: "force_push", Description: "Force push", ParamsJSON: `{}`}, - } - - err := rt.applyConfigToolFilter("github", tools) - require.NoError(t, err) - - // Listed tools must be disabled - for _, blocked := range []string{"delete_repo", "force_push"} { - record, err := rt.storageManager.GetToolApproval("github", blocked) - require.NoError(t, err, "expected approval record for %q", blocked) - assert.True(t, record.Disabled, "tool %q should be disabled", blocked) - } - - // Non-listed tools should remain enabled - record, err := rt.storageManager.GetToolApproval("github", "list_repos") - if err == nil && record != nil { - assert.False(t, record.Disabled, "tool %q should be enabled", "list_repos") - } + assert.True(t, rt.IsToolConfigDenied("github", "delete_repo"), "delete_repo in denylist → denied") + assert.True(t, rt.IsToolConfigDenied("github", "force_push"), "force_push in denylist → denied") + assert.False(t, rt.IsToolConfigDenied("github", "list_repos"), "list_repos not in denylist → allowed") } -// TestApplyConfigToolFilter_NoFilter_NoChanges verifies that when neither -// enabled_tools nor disabled_tools is set, no records are written. -func TestApplyConfigToolFilter_NoFilter_NoChanges(t *testing.T) { +func TestIsToolConfigDenied_NoFilter(t *testing.T) { rt := setupConfigFilterRuntime(t, []*config.ServerConfig{ {Name: "github", Enabled: true}, }) - tools := []*config.ToolMetadata{ - {ServerName: "github", Name: "list_issues", Description: "List issues", ParamsJSON: `{}`}, - } - - err := rt.applyConfigToolFilter("github", tools) - require.NoError(t, err) - - // No approval record should have been written - _, err = rt.storageManager.GetToolApproval("github", "list_issues") - assert.ErrorIs(t, err, storage.ErrToolApprovalNotFound) + assert.False(t, rt.IsToolConfigDenied("github", "any_tool"), "no filter → all tools allowed") } -// TestApplyConfigToolFilter_EnabledTools_ReEnablesTool verifies that a tool -// previously disabled (e.g. by the API) is re-enabled if it appears in -// enabled_tools on the next applyConfigToolFilter call. -func TestApplyConfigToolFilter_EnabledTools_ReEnablesTool(t *testing.T) { - rt := setupConfigFilterRuntime(t, []*config.ServerConfig{ - { - Name: "github", - Enabled: true, - EnabledTools: []string{"list_issues", "get_issue"}, - }, - }) - - // Manually mark get_issue as disabled (simulating a prior API call) - require.NoError(t, rt.storageManager.SaveToolApproval(&storage.ToolApprovalRecord{ - ServerName: "github", - ToolName: "get_issue", - Status: storage.ToolApprovalStatusApproved, - Disabled: true, - })) - - tools := []*config.ToolMetadata{ - {ServerName: "github", Name: "list_issues", Description: "List issues", ParamsJSON: `{}`}, - {ServerName: "github", Name: "get_issue", Description: "Get issue", ParamsJSON: `{}`}, - } - - err := rt.applyConfigToolFilter("github", tools) - require.NoError(t, err) +func TestIsToolConfigDenied_UnknownServer(t *testing.T) { + rt := setupConfigFilterRuntime(t, nil) - // get_issue is in the enabled_tools list — must be re-enabled - record, err := rt.storageManager.GetToolApproval("github", "get_issue") - require.NoError(t, err) - assert.False(t, record.Disabled, "get_issue should be re-enabled by config") + assert.False(t, rt.IsToolConfigDenied("nonexistent", "any_tool")) } -// TestApplyDifferentialToolUpdate_RespectsEnabledToolsConfig is an integration -// test verifying that applyDifferentialToolUpdate honours enabled_tools from the -// server config: tools not in the list end up with Disabled=true in storage. -func TestApplyDifferentialToolUpdate_RespectsEnabledToolsConfig(t *testing.T) { +// TestIsToolConfigDenied_UserDisabledPreserved verifies that setting Disabled in BBolt +// does NOT affect IsToolConfigDenied — the two layers are independent. +func TestIsToolConfigDenied_UserDisabledPreserved(t *testing.T) { rt := setupConfigFilterRuntime(t, []*config.ServerConfig{ - { - Name: "github", - Enabled: true, - EnabledTools: []string{"list_issues"}, - }, + {Name: "github", Enabled: true, EnabledTools: []string{"list_issues"}}, }) - tools := []*config.ToolMetadata{ - {ServerName: "github", Name: "list_issues", Description: "List issues", ParamsJSON: `{}`}, - {ServerName: "github", Name: "create_issue", Description: "Create issue", ParamsJSON: `{}`}, - } - - err := rt.applyDifferentialToolUpdate(t.Context(), "github", tools) + // User disables list_issues manually + err := rt.SetToolEnabled("github", "list_issues", false, "user") require.NoError(t, err) - blocked, err := rt.storageManager.GetToolApproval("github", "create_issue") - require.NoError(t, err) - assert.True(t, blocked.Disabled, "create_issue should be disabled by enabled_tools config") + // Config still allows it — user preference is separate + assert.False(t, rt.IsToolConfigDenied("github", "list_issues"), + "config allows list_issues; user-disabled state must not affect IsToolConfigDenied") } \ No newline at end of file diff --git a/internal/runtime/tool_quarantine.go b/internal/runtime/tool_quarantine.go index a4ff96ef..d04651cd 100644 --- a/internal/runtime/tool_quarantine.go +++ b/internal/runtime/tool_quarantine.go @@ -922,71 +922,15 @@ func (r *Runtime) emitToolQuarantineEvent(serverName, toolName, action, oldHash, r.publishEvent(newEvent(EventTypeActivityToolQuarantineChange, payload)) } -// applyConfigToolFilter syncs the enabled_tools / disabled_tools lists from -// the server's static config into BBolt ToolApprovalRecord.Disabled flags. -// It is called from applyDifferentialToolUpdate so the config-declared filter -// is enforced on every connect / tool-refresh cycle. -// -// - enabled_tools (allowlist): every tool NOT in the list is disabled. -// - disabled_tools (denylist): every tool IN the list is disabled; others -// are (re-)enabled so a tool removed from the denylist becomes visible. -// -// When neither field is set the function is a no-op — no records are written. -func (r *Runtime) applyConfigToolFilter(serverName string, tools []*config.ToolMetadata) error { - if r.storageManager == nil { - return nil - } - - // Read from the in-memory config snapshot (EnabledTools/DisabledTools are - // static declarations, not runtime state — no BBolt read needed). - var serverCfg *config.ServerConfig +// IsToolConfigDenied reports whether toolName is denied by the server's static +// enabled_tools / disabled_tools config. Evaluated at call time — nothing is +// written to BBolt. Returns false (allow) when the server is unknown or has no +// filter configured. +func (r *Runtime) IsToolConfigDenied(serverName, toolName string) bool { for _, sc := range r.Config().Servers { if sc.Name == serverName { - serverCfg = sc - break + return !sc.IsToolAllowedByConfig(toolName) } } - if serverCfg == nil { - return nil - } - - hasAllowList := len(serverCfg.EnabledTools) > 0 - hasDenyList := len(serverCfg.DisabledTools) > 0 - - if !hasAllowList && !hasDenyList { - return nil - } - - allowSet := make(map[string]bool, len(serverCfg.EnabledTools)) - for _, t := range serverCfg.EnabledTools { - allowSet[t] = true - } - denySet := make(map[string]bool, len(serverCfg.DisabledTools)) - for _, t := range serverCfg.DisabledTools { - denySet[t] = true - } - - for _, tool := range tools { - toolName := tool.Name - if idx := strings.Index(toolName, ":"); idx != -1 { - toolName = toolName[idx+1:] - } - - var shouldEnable bool - if hasAllowList { - shouldEnable = allowSet[toolName] - } else { - shouldEnable = !denySet[toolName] - } - - if _, err := r.setToolEnabledNoEmit(serverName, toolName, shouldEnable, "config"); err != nil { - r.logger.Warn("applyConfigToolFilter: failed to set tool enabled state", - zap.String("server", serverName), - zap.String("tool", toolName), - zap.Bool("enabled", shouldEnable), - zap.Error(err)) - } - } - - return nil + return false } From 7f38905bb41f9943c308ac024982ca9ca3fa0706 Mon Sep 17 00:00:00 2001 From: Nicolas Laurance Date: Fri, 15 May 2026 16:46:36 +0100 Subject: [PATCH 04/11] feat(runtime): skip config-denied tools in SetAllToolsEnabled bulk toggle --- internal/runtime/tool_config_filter_test.go | 21 +++++++++++++++++++++ internal/runtime/tool_quarantine.go | 6 ++++++ 2 files changed, 27 insertions(+) diff --git a/internal/runtime/tool_config_filter_test.go b/internal/runtime/tool_config_filter_test.go index 31184d01..11229daa 100644 --- a/internal/runtime/tool_config_filter_test.go +++ b/internal/runtime/tool_config_filter_test.go @@ -73,4 +73,25 @@ func TestIsToolConfigDenied_UserDisabledPreserved(t *testing.T) { // Config still allows it — user preference is separate assert.False(t, rt.IsToolConfigDenied("github", "list_issues"), "config allows list_issues; user-disabled state must not affect IsToolConfigDenied") +} + +func TestSetAllToolsEnabled_SkipsConfigDenied(t *testing.T) { + rt := setupConfigFilterRuntime(t, []*config.ServerConfig{ + {Name: "github", Enabled: true, EnabledTools: []string{"list_issues"}}, + }) + + // Seed approval records so SetAllToolsEnabled has tools to iterate over. + require.NoError(t, rt.SetToolEnabled("github", "list_issues", false, "user")) + require.NoError(t, rt.SetToolEnabled("github", "create_issue", false, "user")) + + changed, err := rt.SetAllToolsEnabled("github", true, "user") + require.NoError(t, err) + + // Only list_issues should flip — create_issue is config-denied. + assert.Equal(t, 1, changed) + + // create_issue must remain disabled in BBolt + record, err := rt.storageManager.GetToolApproval("github", "create_issue") + require.NoError(t, err) + assert.True(t, record.Disabled, "create_issue must remain disabled; config denies it") } \ No newline at end of file diff --git a/internal/runtime/tool_quarantine.go b/internal/runtime/tool_quarantine.go index d04651cd..2e731f18 100644 --- a/internal/runtime/tool_quarantine.go +++ b/internal/runtime/tool_quarantine.go @@ -765,6 +765,12 @@ func (r *Runtime) SetAllToolsEnabled(serverName string, enabled bool, updatedBy changed := 0 for _, toolName := range toolNames { + // Never enable a tool the config denies — user-owned Disabled flag is + // irrelevant here; enforcement is in isToolCallable, but we avoid a + // misleading record.Disabled=false for a hard-off tool. + if enabled && r.IsToolConfigDenied(serverName, toolName) { + continue + } flipped, setErr := r.setToolEnabledNoEmit(serverName, toolName, enabled, updatedBy) if setErr != nil { r.logger.Warn("Failed to toggle tool in bulk operation", From 7ddbca2f3ec7f9db347839e16c6ea5185ae61117 Mon Sep 17 00:00:00 2001 From: Nicolas Laurance Date: Fri, 15 May 2026 16:54:52 +0100 Subject: [PATCH 05/11] feat(server): enforce config tool filter in isToolCallable Add IsToolConfigDenied delegation on *Server and insert a config-layer check in isToolCallable so tools denied by enabled_tools/disabled_tools in the server config are hard-off at MCP call time, evaluated at runtime without touching BBolt storage. Co-Authored-By: Claude Sonnet 4.6 --- internal/server/mcp.go | 8 ++++++++ internal/server/server.go | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/internal/server/mcp.go b/internal/server/mcp.go index 0b517329..fdda5901 100644 --- a/internal/server/mcp.go +++ b/internal/server/mcp.go @@ -4574,6 +4574,14 @@ func (p *MCPProxyServer) isToolCallable(serverName, toolName string) bool { return false } + // Config-layer filter — evaluated at call time, never written to BBolt. + // A tool absent from enabled_tools or present in disabled_tools is hard off. + if p.mainServer != nil && p.mainServer.runtime != nil { + if p.mainServer.runtime.IsToolConfigDenied(serverName, toolName) { + return false + } + } + approval, err := p.storage.GetToolApproval(serverName, toolName) switch { case err == nil: diff --git a/internal/server/server.go b/internal/server/server.go index 0a71555e..c0cb9a63 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2490,6 +2490,12 @@ func (s *Server) SetAllToolsEnabled(serverName string, enabled bool, updatedBy s return s.runtime.SetAllToolsEnabled(serverName, enabled, updatedBy) } +// IsToolConfigDenied reports whether toolName is denied by the server's static +// enabled_tools / disabled_tools config. +func (s *Server) IsToolConfigDenied(serverName, toolName string) bool { + return s.runtime.IsToolConfigDenied(serverName, toolName) +} + // GetToolApproval returns the approval record for a specific tool (Spec 032). func (s *Server) GetToolApproval(serverName, toolName string) (*storage.ToolApprovalRecord, error) { return s.runtime.GetToolApproval(serverName, toolName) From d0172634550b0e7530aa4ec7faf9a2960dd2f482 Mon Sep 17 00:00:00 2001 From: Nicolas Laurance Date: Fri, 15 May 2026 17:01:34 +0100 Subject: [PATCH 06/11] feat(api): expose config_denied on tool response; reject enabling config-denied tools - Add ConfigDenied bool field to contracts.Tool (json: config_denied,omitempty) - Enrich config_denied in handleGetServerTools via IsToolConfigDenied interface - Return HTTP 409 in handleSetToolEnabled when req.Enabled is true for a config-denied tool - Remove debug fmt.Printf lines from enrichment loop; use logger.Debug instead Co-Authored-By: Claude Sonnet 4.6 --- internal/contracts/types.go | 3 +++ internal/httpapi/server.go | 24 +++++++++++++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/internal/contracts/types.go b/internal/contracts/types.go index ef16a303..3f892e0f 100644 --- a/internal/contracts/types.go +++ b/internal/contracts/types.go @@ -195,6 +195,9 @@ type Tool struct { // available without a second round-trip to the approvals endpoint. Absent // in the JSON when false (default) to keep responses compact. Disabled bool `json:"disabled,omitempty"` + // ConfigDenied is true when the tool is denied by the server's static + // enabled_tools / disabled_tools config. The user cannot override this toggle. + ConfigDenied bool `json:"config_denied,omitempty"` } // SearchResult represents a search result for tools diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 9fc12adf..2617b6f2 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -2437,6 +2437,12 @@ func (s *Server) handleGetServerTools(w http.ResponseWriter, r *http.Request) { // explicitly toggled. enrichedCount := 0 var firstErr error + + type configDeniedChecker interface { + IsToolConfigDenied(serverName, toolName string) bool + } + configChecker, hasConfigChecker := s.controller.(configDeniedChecker) + for i := range typedTools { record, err := s.controller.GetToolApproval(serverID, typedTools[i].Name) if err == nil && record != nil { @@ -2446,11 +2452,12 @@ func (s *Server) handleGetServerTools(w http.ResponseWriter, r *http.Request) { } else if i == 0 { firstErr = err } + if hasConfigChecker { + typedTools[i].ConfigDenied = configChecker.IsToolConfigDenied(serverID, typedTools[i].Name) + } } if firstErr != nil { - fmt.Printf("[DEBUG] Tool approval enrichment: server=%s enriched=%d/%d first_error=%v\n", serverID, enrichedCount, len(typedTools), firstErr) - } else { - fmt.Printf("[DEBUG] Tool approval enrichment: server=%s enriched=%d/%d\n", serverID, enrichedCount, len(typedTools)) + s.logger.Debug("Tool approval enrichment partial", "server", serverID, "enriched", enrichedCount, "total", len(typedTools), "error", firstErr) } // Sort: pending/changed tools first, then approved @@ -4041,6 +4048,17 @@ func (s *Server) handleSetToolEnabled(w http.ResponseWriter, r *http.Request) { return } + // Reject attempts to enable a tool the server config forbids. + if req.Enabled { + if configChecker, ok := s.controller.(interface { + IsToolConfigDenied(serverName, toolName string) bool + }); ok && configChecker.IsToolConfigDenied(serverID, toolName) { + s.writeError(w, r, http.StatusConflict, + "tool is denied by server config (enabled_tools / disabled_tools); remove the config restriction to enable this tool") + return + } + } + controller, ok := s.controller.(interface { SetToolEnabled(serverName, toolName string, enabled bool, updatedBy string) error }) From 166cdc119de8041eb0215f75495ea281c22d51b6 Mon Sep 17 00:00:00 2001 From: Nicolas Laurance Date: Fri, 15 May 2026 17:06:40 +0100 Subject: [PATCH 07/11] feat(ui): show locked badge and disable toggle for config-denied tools Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/views/ServerDetail.vue | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/frontend/src/views/ServerDetail.vue b/frontend/src/views/ServerDetail.vue index 135ba72c..127ac0fb 100644 --- a/frontend/src/views/ServerDetail.vue +++ b/frontend/src/views/ServerDetail.vue @@ -388,7 +388,14 @@ +