Skip to content

feat: add tool toggling via CLI args and project config#3

Open
hamb3r wants to merge 2 commits intoMikeRecognex:mainfrom
hamb3r:feat/tool-toggling
Open

feat: add tool toggling via CLI args and project config#3
hamb3r wants to merge 2 commits intoMikeRecognex:mainfrom
hamb3r:feat/tool-toggling

Conversation

@hamb3r
Copy link

@hamb3r hamb3r commented Feb 27, 2026

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:

  1. 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 for search_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.

  2. No project-level customization. Different projects have different needs. A small utility library has no use for get_call_chain or get_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.

  3. No operator control. An operator deploying the server for a team may want to restrict the tool surface — for example, disabling search_codebase in 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-tools

mcp-codebase-index --disabled-tools search_codebase,get_call_chain

Comma-separated list of tool names. Parsed via argparse in main_sync(), threaded through main() into _init_disabled_tools(). This is the primary mechanism for operator control — the argument appears in the command array of the MCP server configuration in claude_desktop_config.json or equivalent.

Uses parse_known_args() rather than parse_args() so that unknown flags passed by MCP hosts do not crash the process with SystemExit. Unknown arguments are logged to stderr and ignored.

Project config: .mcp-codebase-index.toml

disabled_tools = ["search_codebase", "get_call_chain"]

A TOML file in the project root (resolved from PROJECT_ROOT env var or cwd). Parsed with tomllib (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

  • Both sources are unioned. If a tool appears in either the CLI argument or the config file, it is disabled.
  • Protected tools (reindex and get_usage_stats) can never be disabled. Attempting to disable them produces a stderr warning and the request is silently dropped. Rationale: reindex is the only way to recover from a stale index, and get_usage_stats is the only observability tool — disabling either would leave the user unable to diagnose problems.
  • Unknown tool names (typos, names from a different version) produce a stderr warning and are ignored. This prevents silent misconfiguration where a user thinks they disabled a tool but the name does not match anything.
  • Resolution happens once at startup (_init_disabled_tools()), before the stdio server loop begins. The disabled set is immutable for the lifetime of the process.

Implementation details

Module state

PROTECTED_TOOLS: frozenset[str] = frozenset({"reindex", "get_usage_stats"})
_disabled_tools: set[str] = set()
_ALL_TOOL_NAMES: frozenset[str] = frozenset()  # set after TOOLS list is defined

_ALL_TOOL_NAMES is initialized at module level after the TOOLS list, as a frozenset comprehension over t.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.toml from the given project root. Returns an empty set if: the file does not exist, the file fails to parse (malformed TOML), the disabled_tools key 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 from main() before the stdio server loop.

list_tools() filtering

@server.list_tools()
async def list_tools() -> list[Tool]:
    if _disabled_tools:
        return [t for t in TOOLS if t.name not in _disabled_tools]
    return TOOLS

The fast path (no disabled tools) returns the TOOLS list directly with zero overhead. The filtered path constructs a new list on each call — this is fine because list_tools() is called once during MCP initialization, not in a hot loop.

call_tool() guard

if name in _disabled_tools:
    return [TextContent(
        type="text",
        text=f"Error: tool '{name}' is disabled.",
    )]

This 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:

  • No indexing work is triggered by a disabled tool call
  • Disabled tools do not appear in usage stats (they are never counted)
  • The error message names the tool, so the agent can adapt

main() and main_sync()

main() gains a cli_disabled: list[str] | None = None parameter and calls _init_disabled_tools(cli_disabled) before entering the stdio server loop.

main_sync() gains an argparse.ArgumentParser with a single --disabled-tools argument. Uses parse_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)

  • No config file present -> empty set
  • Valid config with two tools -> correct set
  • Empty list -> empty set
  • Value is a string instead of a list -> empty set (with warning)
  • Value is a list of non-strings -> empty set (with warning)
  • Malformed TOML -> empty set (with warning)
  • TOML present but disabled_tools key missing -> empty set
  • Whitespace in values stripped -> correct names
  • Blank strings filtered out -> only non-empty names

TestInitDisabledTools (7 tests)

  • CLI only -> correct set
  • Config only -> correct set
  • CLI + config -> union
  • Protected tools in input -> stripped, non-protected retained
  • Unknown tools in input -> stripped, known tools retained
  • Empty CLI list -> empty set
  • None CLI + no config -> empty set

TestListToolsFiltering (3 tests)

  • No disabled tools -> all 18 tools returned
  • Two tools disabled -> 16 tools returned, disabled ones absent
  • Protected tools always present even when other tools disabled

TestCallToolGuard (3 tests)

  • Disabled tool -> error response with tool name
  • Disabled tool -> not counted in _tool_call_counts
  • Enabled tool -> proceeds normally (with mocked _ensure_index)

TestCliParsing (5 tests)

  • --disabled-tools parsed correctly
  • Whitespace in comma-separated values stripped
  • No flag -> None
  • Unknown args do not raise SystemExit (parse_known_args resilience)
  • Unknown args coexist with --disabled-tools

All async handlers (list_tools, call_tool) are tested via asyncio.run() rather than pytest-asyncio, consistent with the project test dependencies (only pytest and ruff in dev extras).

What this does NOT do

  • No runtime toggling. The disabled set is fixed at startup. There is no enable_tool / disable_tool MCP 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.
  • No per-tool access control. This is a binary enable/disable, not a permission system. There is no "read-only" mode, no authentication, no per-user scoping.
  • No changes to tool definitions. Disabled tools are simply omitted from list_tools() and rejected in call_tool(). The TOOLS list itself is unchanged.
  • No changes to indexing behavior. Disabling a tool does not skip any indexing work. The full index is still built. This could be optimized in the future (e.g. skip dependency graph construction if all graph tools are disabled), but that is a separate concern.

Verification

ruff check src/ tests/               # passes
pytest tests/test_tool_toggle.py -v  # 27/27 pass
pytest tests/ -v                     # 398 pass, 4 pre-existing failures in import graph tests

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).
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.

1 participant