feat: add tool toggling via CLI args and project config#3
Open
hamb3r wants to merge 2 commits intoMikeRecognex:mainfrom
Open
feat: add tool toggling via CLI args and project config#3hamb3r wants to merge 2 commits intoMikeRecognex:mainfrom
hamb3r wants to merge 2 commits intoMikeRecognex:mainfrom
Conversation
Adds --disabled-tools CLI arg and .mcp-codebase-index.toml support to selectively disable MCP tools. Both sources are unioned; protected tools (reindex, get_usage_stats) cannot be disabled. Unknown tool names are warned and ignored.
…, CLI tests - Switch parse_args() to parse_known_args() so unknown flags from MCP hosts do not kill the process with SystemExit. - Neutralize call_tool error to "tool is disabled" (covers both CLI and config sources). - Strip whitespace and filter blanks from TOML disabled_tools values. - Add 7 new tests: TOML whitespace/blank handling (2), CLI argv parsing (5 including unknown-args resilience).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The MCP server exposes all 18 tools unconditionally. Every tool appears in the
list_tools()response regardless of whether the consuming agent actually needs it. This creates several practical problems:Context pollution. Each tool definition occupies tokens in the agent's context window. An agent that only needs structural queries (e.g.
get_functions,find_symbol) still receives tool definitions forsearch_codebase,get_call_chain,get_change_impact, and every other tool. For models with limited context or projects where only a subset of capabilities is relevant, this is wasteful.No project-level customization. Different projects have different needs. A small utility library has no use for
get_call_chainorget_change_impact— those tools exist for navigating large dependency graphs. There is currently no way to express "this project doesn't need these tools" in a way that persists across sessions.No operator control. An operator deploying the server for a team may want to restrict the tool surface — for example, disabling
search_codebasein favor of the agent's built-in grep, or disabling graph-traversal tools to reduce indexing overhead. There is no mechanism for this today.Serena (another MCP server used in this project's development workflow) solves a similar problem with its own tool toggling mechanism. The approach here is deliberately simpler: no runtime toggling, no per-tool access control, just a static disable list resolved at startup.
Solution
Two configuration sources, unioned at startup:
CLI argument:
--disabled-toolsComma-separated list of tool names. Parsed via
argparseinmain_sync(), threaded throughmain()into_init_disabled_tools(). This is the primary mechanism for operator control — the argument appears in thecommandarray of the MCP server configuration inclaude_desktop_config.jsonor equivalent.Uses
parse_known_args()rather thanparse_args()so that unknown flags passed by MCP hosts do not crash the process withSystemExit. Unknown arguments are logged to stderr and ignored.Project config:
.mcp-codebase-index.tomlA TOML file in the project root (resolved from
PROJECT_ROOTenv var or cwd). Parsed withtomllib(stdlib since Python 3.11, which is our minimum version). This is the mechanism for project-level customization — the file is checked into the repo and applies to all agents working on that project.Values are stripped of leading/trailing whitespace and blank strings are filtered out, so
" search_codebase "is treated as"search_codebase".Resolution rules
reindexandget_usage_stats) can never be disabled. Attempting to disable them produces a stderr warning and the request is silently dropped. Rationale:reindexis the only way to recover from a stale index, andget_usage_statsis the only observability tool — disabling either would leave the user unable to diagnose problems._init_disabled_tools()), before the stdio server loop begins. The disabled set is immutable for the lifetime of the process.Implementation details
Module state
_ALL_TOOL_NAMESis initialized at module level after theTOOLSlist, as afrozensetcomprehension overt.name for t in TOOLS. This is used for unknown-name validation in_init_disabled_tools()._load_disabled_tools_from_config(project_root: str) -> set[str]Reads
.mcp-codebase-index.tomlfrom the given project root. Returns an empty set if: the file does not exist, the file fails to parse (malformed TOML), thedisabled_toolskey is missing, or the value is not a list of strings. Every failure path logs a warning to stderr and returns gracefully — a broken config file never prevents the server from starting. String values are stripped of whitespace and blank entries are filtered out._init_disabled_tools(cli_disabled, *, project_root)Unions the CLI list and config set. Strips protected tools (with warning). Strips unknown tools (with warning). Assigns the result to the module-level
_disabled_tools. Logs the final disabled set to stderr if non-empty. Called frommain()before the stdio server loop.list_tools()filteringThe fast path (no disabled tools) returns the
TOOLSlist directly with zero overhead. The filtered path constructs a new list on each call — this is fine becauselist_tools()is called once during MCP initialization, not in a hot loop.call_tool()guardThis check happens before
_ensure_index(), before tool call counting, and before the try/except block. A disabled tool call is a no-op that returns immediately with a clear error message. This means:main()andmain_sync()main()gains acli_disabled: list[str] | None = Noneparameter and calls_init_disabled_tools(cli_disabled)before entering the stdio server loop.main_sync()gains anargparse.ArgumentParserwith a single--disabled-toolsargument. Usesparse_known_args()so unknown flags from MCP hosts do not crash the process. Unknown arguments are logged to stderr.Tests
27 tests in
tests/test_tool_toggle.py, organized into five test classes:TestLoadDisabledToolsFromConfig(9 tests)disabled_toolskey missing -> empty setTestInitDisabledTools(7 tests)TestListToolsFiltering(3 tests)TestCallToolGuard(3 tests)_tool_call_counts_ensure_index)TestCliParsing(5 tests)--disabled-toolsparsed correctlyNone--disabled-toolsAll async handlers (
list_tools,call_tool) are tested viaasyncio.run()rather thanpytest-asyncio, consistent with the project test dependencies (onlypytestandruffin dev extras).What this does NOT do
enable_tool/disable_toolMCP call. This is intentional — runtime toggling would require state synchronization between the server and the agent, which is complex and error-prone for marginal benefit.list_tools()and rejected incall_tool(). TheTOOLSlist itself is unchanged.Verification