Skip to content

fix(mcp): gate utility stubs on server-advertised capabilities - #18052

Closed
nikolay-bratanov wants to merge 1 commit into
NousResearch:mainfrom
nikolay-bratanov:fix/mcp-utility-stubs-by-capabilities
Closed

fix(mcp): gate utility stubs on server-advertised capabilities#18052
nikolay-bratanov wants to merge 1 commit into
NousResearch:mainfrom
nikolay-bratanov:fix/mcp-utility-stubs-by-capabilities

Conversation

@nikolay-bratanov

Copy link
Copy Markdown

Summary

Fixes #18051. The four MCP utility schemas (list_resources / read_resource / list_prompts / get_prompt) are registered for every connected MCP server, even when the server advertises only the tools capability. Today's gate uses hasattr(server.session, required_method), which is always True because mcp.ClientSession defines all four methods on the class regardless of what the remote server actually supports.

The InitializeResult returned from await session.initialize() is the source of truth — result.capabilities.resources and result.capabilities.prompts are non-None only when the server implements those request types — but it was being discarded.

Changes

  • MCPServerTask.__slots__: add "initialize_result".
  • MCPServerTask.__init__: initialise self.initialize_result: Optional[Any] = None.
  • All three await session.initialize() call sites (_run_http, _run_http reconnect path, _run_stdio): assign the result into self.initialize_result.
  • _select_utility_schemas: when initialize_result is present, skip list_resources / read_resource if capabilities.resources is None, and skip list_prompts / get_prompt if capabilities.prompts is None. When initialize_result is None (older test fixtures, no real connect), fall back to the existing hasattr check so behaviour on that path is unchanged.

Verification

Direct unit-style test against a freshly patched tree (venv/bin/python against tools.mcp_tool):

from types import SimpleNamespace
from tools.mcp_tool import _select_utility_schemas, MCPServerTask

# tools-only server (Context7 case)
s1 = MCPServerTask("tools_only")
s1.session = SimpleNamespace(list_resources=lambda: None, read_resource=lambda x: None,
                              list_prompts=lambda: None, get_prompt=lambda *a: None)
s1.initialize_result = SimpleNamespace(capabilities=SimpleNamespace(
    tools=SimpleNamespace(listChanged=True), prompts=None, resources=None))
print([e["handler_key"] for e in _select_utility_schemas("x", s1, {})])
# -> []

# full-caps server
s2 = MCPServerTask("full")
s2.session = s1.session
s2.initialize_result = SimpleNamespace(capabilities=SimpleNamespace(
    tools=True, prompts=SimpleNamespace(), resources=SimpleNamespace()))
print([e["handler_key"] for e in _select_utility_schemas("x", s2, {})])
# -> ['list_resources', 'read_resource', 'list_prompts', 'get_prompt']

# legacy fallback (no initialize_result)
s3 = MCPServerTask("legacy")
s3.session = s1.session
print([e["handler_key"] for e in _select_utility_schemas("x", s3, {})])
# -> ['list_resources', 'read_resource', 'list_prompts', 'get_prompt']

End-to-end: I have this running locally against @upstash/context7-mcp (which is the canonical "tools-only" server). With the patch applied, _register_server_tools registers exactly two LLM-visible tools (mcp_context7_resolve_library_id, mcp_context7_query_docs) instead of the previous six.

Risk

  • The new initialize_result slot is None until the first successful initialize(). The fallback path keeps existing behaviour for code paths that haven't reached that point yet.
  • No production behaviour change for servers that do advertise prompts/resources — they get the same schemas as before.
  • Minimal blast radius: one new slot, three single-line assignments, one if/else block in the existing utility-selection function. No public-API change.

Notes

I haven't added a unit test in this PR because the existing tests in tests/tools/test_mcp_tool.py mock ClientSession rather than the full connect/initialize flow, and adding a capabilities-gating test would require either a new fixture for InitializeResult or relaxing the existing fixtures. Happy to follow up with one in a separate commit if you'd like — let me know which fixture style you prefer.

The four MCP utility schemas (`list_resources` / `read_resource` /
`list_prompts` / `get_prompt`) were registered for every connected MCP
server because `_select_utility_schemas` checked
`hasattr(server.session, required_method)` — which is always True, since
`mcp.ClientSession` defines all four methods on the class regardless of
what the remote server supports.

Servers that advertise only the `tools` capability (e.g. Context7,
`@upstash/context7-mcp` v2.2.3) therefore exposed 4 dead schemas to the
LLM. Calling those stubs returns JSON-RPC -32601 "Method not found",
which leads the model to report the server as broken even when the real
`tools/*` methods work fine.

Fix:

* Cache the `InitializeResult` returned from `session.initialize()` on
  `MCPServerTask.initialize_result` (added to `__slots__`, initialised
  to None, populated at all three `session.initialize()` call sites).
* In `_select_utility_schemas`, inspect
  `initialize_result.capabilities.resources` /
  `.capabilities.prompts` and skip the corresponding stubs when those
  sub-objects are None (per the MCP spec, they are non-None only when
  the server actually implements those request types).
* Keep the previous `hasattr` check as a fallback for the rare case
  where `initialize_result` is missing (e.g. older test fixtures), so
  pre-existing behaviour is unchanged on that code path.

Closes NousResearch#18051

@trevorgordon981 trevorgordon981 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. The fix is minimal, surgical, and addresses the root cause: the was being discarded, so we couldn’t gate utility stubs on actual server capabilities.

Verified:

  • ✅ Unit test (inline) passes: tools-only server → , full-caps → all 4 utilities, legacy fallback → all 4
  • ✅ All 182 MCP tool tests pass
  • ✅ No breaking changes: defaults to , fallback preserves old behavior
  • ✅ Low risk: one new slot, 3 assignments, one gated if/else block

The change correctly filters out , , , and for servers like that only advertise . Well done.

@trevorgordon981 trevorgordon981 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. The fix is minimal, surgical, and addresses the root cause: the initialize_result was being discarded, so we could not gate utility stubs on actual server capabilities.

Verified:

  • Unit test (inline) passes: tools-only server -> [], full-caps -> all 4 utilities, legacy fallback -> all 4
  • All 182 MCP tool tests pass
  • No breaking changes: initialize_result defaults to None, fallback preserves old behavior
  • Low risk: one new slot, 3 assignments, one gated if/else block

The change correctly filters out list_resources, read_resource, list_prompts, and get_prompt for servers like @upstash/context7-mcp that only advertise tools. Well done.

@teknium1

Copy link
Copy Markdown
Contributor

Automated hermes-sweeper review found this fix is already implemented on current main via #21347.

Evidence:

Thanks for the original repro and root-cause analysis; it matches what landed on main.

@teknium1 teknium1 closed this Jun 10, 2026
@teknium1 teknium1 added the sweeper:implemented-on-main Sweeper: behavior already present on current main label Jun 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P2 Medium — degraded but workaround exists sweeper:implemented-on-main Sweeper: behavior already present on current main tool/mcp MCP client and OAuth type/bug Something isn't working

Projects

None yet

4 participants