Skip to content

feat(config): enabled_tools/disabled_tools per-server allowlist/denylist in mcp_config.json#468

Open
nlaurance wants to merge 1 commit into
smart-mcp-proxy:mainfrom
nlaurance:feat/config-tool-allowlist
Open

feat(config): enabled_tools/disabled_tools per-server allowlist/denylist in mcp_config.json#468
nlaurance wants to merge 1 commit into
smart-mcp-proxy:mainfrom
nlaurance:feat/config-tool-allowlist

Conversation

@nlaurance
Copy link
Copy Markdown

@nlaurance nlaurance commented May 14, 2026

Summary

  • Adds enabled_tools (allowlist) and disabled_tools (denylist) fields to ServerConfig so operators can declare tool visibility statically in mcp_config.json without needing to call the API or CLI after every fresh install
  • The two fields are mutually exclusive — config validation rejects a server with both set
  • On every applyDifferentialToolUpdate (server connect / tool refresh), the new applyConfigToolFilter syncs the config declarations into BBolt ToolApprovalRecord.Disabled flags, so all existing enforcement paths (isToolCallable, retrieve_tools pre-ranking, call_tool_*, direct mode) pick up the change automatically

Usage

{
  "mcpServers": [
    {
      "name": "github",
      "url": "https://api.github.com/mcp",
      "enabled_tools": ["list_issues", "get_issue", "list_repos"]
    },
    {
      "name": "filesystem",
      "url": "...",
      "disabled_tools": ["write_file", "delete_file", "execute_code"]
    }
  ]
}

Test plan

  • go test ./internal/config/ ./internal/runtime/ — 349 tests pass
  • TestApplyConfigToolFilter_EnabledTools_DisablesNonListedTools — allowlist hides unlisted tools
  • TestApplyConfigToolFilter_DisabledTools_DisablesListedTools — denylist hides specified tools
  • TestApplyConfigToolFilter_NoFilter_NoChanges — no records written when neither field is set
  • TestApplyConfigToolFilter_EnabledTools_ReEnablesTool — tool moved back into allowlist is re-enabled
  • TestApplyDifferentialToolUpdate_RespectsEnabledToolsConfig — end-to-end through applyDifferentialToolUpdate
  • TestValidateDetailed/enabled_tools_and_disabled_tools_are_mutually_exclusive — validation rejects both fields set simultaneously
  • go build ./... — clean build

🤖 Generated with Claude Code

…enylist

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 <noreply@anthropic.com>
@Dumbris
Copy link
Copy Markdown
Member

Dumbris commented May 15, 2026

Thanks @nlaurance — clean implementation and genuinely thorough tests. I support the idea of config-based tool filtering — declaring tool visibility in mcp_config.json is the right capability for fresh installs and gitops setups, and the allowlist's fail-closed posture against tool-surface growth is exactly in our threat model.

My concern is purely the interaction model with the per-tool enable/disable that recently landed (#463 Web UI, #447, REST/CLI SetToolEnabled). applyConfigToolFilter writes config intent into the same ToolApprovalRecord.Disabled BBolt flag those paths own, on every applyDifferentialToolUpdate (i.e. every reconnect). Concretely this means:

  • There's no durable provenanceToolApprovalRecord has no source field, so after the first write a config-disabled tool is indistinguishable from a user-disabled one. updatedBy="config" is dropped.
  • A tool the user manually disabled in the UI that's in enabled_tools gets silently re-enabled on every reconnectTestApplyConfigToolFilter_EnabledTools_ReEnablesTool pins this as intended, but that's the behavior I'd push back on.
  • It emits a tool_disabled activity event on every reconnect, polluting the audit trail.
  • A config-denied tool keeps its pending/changed quarantine status, so upstream inspect/approve still invites the user to approve a tool config will keep hidden.

Definition I'd like us to agree on

For me, config-based filtering should mean hard-off for unwanted tools, and for that to be solid it has to be transparent, predictable, and surfaced in the UI and CLI — never a silent toggle that snaps back. Precisely:

  • Effective visibility = configAllows(tool) AND userEnabled(tool). Config can only restrict further; it can never force a tool back on against the user.
  • A tool denied by config (in disabled_tools, or absent from a non-empty enabled_tools allowlist) is HARD OFF.
  • Config-denied tools are shown in the Web UI and CLI as locked + badged ("disabled by mcp_config.json") — toggle is read-only, and the server rejects an override attempt from a stale client.
  • A config-denied tool that is also pending/changed in quarantine shows the config lock, not an "approve me" affordance.
  • Config-allowed tools remain normally user-toggleable.

Spec use cases

  1. disabled_tools: ["delete_repo"]delete_repo HARD OFF, UI shows it locked+badged, upstream inspect shows SOURCE=config. User cannot enable it.
  2. enabled_tools: ["list_issues"], server also exposes create_issuecreate_issue is HARD OFF (implicit deny), locked+badged. list_issues is a normal toggle the user may still disable.
  3. User disables list_issues (in enabled_tools) via the UI → it stays disabled across reconnects; config does not re-enable it.
  4. Operator has an allowlist set and the upstream server adds new tools → new tools are denied by default, no manual action needed.
  5. A pending tool that's also config-denied → shown as config-locked, not surfaced for approval.

Implementation-wise this means config should override the stored DB enable state at evaluation time rather than overwriting it: evaluate the config layer in isToolCallable (the single chokepoint that already covers all enforcement paths + code_execution), keep record.Disabled exclusively user-owned, expose a derived config_managed field on the tools API so the UI/CLI can lock+badge, and stop emitting per-reconnect audit events.

Does this definition work for you? If we're aligned on it, I'm happy to pair on this PR and convert the current behavior to override the DB option after config read along these lines — the config fields + mutual-exclusion validation you've already written are solid and stay as-is.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants