Skip to content

Commit 04133d5

Browse files
mr-brobotclaude
andcommitted
fix(clientsessiongroup): only query negotiated capabilities
ClientSessionGroup._aggregate_components queried prompts, resources, and tools unconditionally on every connect, ignoring the ServerCapabilities returned by initialize(). A server that advertised only some of these (e.g. tools) returned JSON-RPC "Method not found" for the rest, which was swallowed into spurious WARNING logs. The MCP lifecycle spec requires clients to only use capabilities that were successfully negotiated. Gate each list_* call on the matching capability from session.initialize_result.capabilities, falling back to the prior unconditional behavior when initialize_result is absent so the existing MCPError handler still covers servers that advertise a capability but fail the method. Fixes #2689 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 616476f commit 04133d5

2 files changed

Lines changed: 80 additions & 25 deletions

File tree

src/mcp/client/session_group.py

Lines changed: 37 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -344,36 +344,48 @@ async def _aggregate_components(self, server_info: types.Implementation, session
344344
tools_temp: dict[str, types.Tool] = {}
345345
tool_to_session_temp: dict[str, mcp.ClientSession] = {}
346346

347+
# Per the MCP lifecycle spec, clients must only use capabilities the
348+
# server successfully negotiated, so gate each list_* call on the
349+
# matching capability rather than issuing requests the server never
350+
# advertised. initialize_result is populated once initialize() has
351+
# completed; if it is missing, fall back to the prior unconditional
352+
# behavior and let the MCPError handler cope with servers that
353+
# advertise a capability but still fail the method.
354+
capabilities = session.initialize_result.capabilities if session.initialize_result is not None else None
355+
347356
# Query the server for its prompts and aggregate to list.
348-
try:
349-
prompts = (await session.list_prompts()).prompts
350-
for prompt in prompts:
351-
name = self._component_name(prompt.name, server_info)
352-
prompts_temp[name] = prompt
353-
component_names.prompts.add(name)
354-
except MCPError as err: # pragma: no cover
355-
logging.warning(f"Could not fetch prompts: {err}")
357+
if capabilities is None or capabilities.prompts is not None:
358+
try:
359+
prompts = (await session.list_prompts()).prompts
360+
for prompt in prompts:
361+
name = self._component_name(prompt.name, server_info)
362+
prompts_temp[name] = prompt
363+
component_names.prompts.add(name)
364+
except MCPError as err: # pragma: no cover
365+
logging.warning(f"Could not fetch prompts: {err}")
356366

357367
# Query the server for its resources and aggregate to list.
358-
try:
359-
resources = (await session.list_resources()).resources
360-
for resource in resources:
361-
name = self._component_name(resource.name, server_info)
362-
resources_temp[name] = resource
363-
component_names.resources.add(name)
364-
except MCPError as err: # pragma: no cover
365-
logging.warning(f"Could not fetch resources: {err}")
368+
if capabilities is None or capabilities.resources is not None:
369+
try:
370+
resources = (await session.list_resources()).resources
371+
for resource in resources:
372+
name = self._component_name(resource.name, server_info)
373+
resources_temp[name] = resource
374+
component_names.resources.add(name)
375+
except MCPError as err: # pragma: no cover
376+
logging.warning(f"Could not fetch resources: {err}")
366377

367378
# Query the server for its tools and aggregate to list.
368-
try:
369-
tools = (await session.list_tools()).tools
370-
for tool in tools:
371-
name = self._component_name(tool.name, server_info)
372-
tools_temp[name] = tool
373-
tool_to_session_temp[name] = session
374-
component_names.tools.add(name)
375-
except MCPError as err: # pragma: no cover
376-
logging.warning(f"Could not fetch tools: {err}")
379+
if capabilities is None or capabilities.tools is not None:
380+
try:
381+
tools = (await session.list_tools()).tools
382+
for tool in tools:
383+
name = self._component_name(tool.name, server_info)
384+
tools_temp[name] = tool
385+
tool_to_session_temp[name] = session
386+
component_names.tools.add(name)
387+
except MCPError as err: # pragma: no cover
388+
logging.warning(f"Could not fetch tools: {err}")
377389

378390
# Clean up exit stack for session if we couldn't retrieve anything
379391
# from the server.

tests/client/test_session_group.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,3 +385,46 @@ async def test_client_session_group_establish_session_parameterized(
385385
# 3. Assert returned values
386386
assert returned_server_info is mock_initialize_result.server_info
387387
assert returned_session is mock_entered_session
388+
389+
390+
@pytest.mark.anyio
391+
@pytest.mark.parametrize("advertised", ["tools", "prompts", "resources"])
392+
async def test_client_session_group_skips_unsupported_capabilities(advertised: str):
393+
"""Only the capability the server advertised is queried during aggregation."""
394+
mock_session = mock.AsyncMock(spec=mcp.ClientSession)
395+
mock_session.initialize_result = types.InitializeResult(
396+
protocol_version=types.LATEST_PROTOCOL_VERSION,
397+
capabilities=types.ServerCapabilities(
398+
tools=types.ToolsCapability() if advertised == "tools" else None,
399+
prompts=types.PromptsCapability() if advertised == "prompts" else None,
400+
resources=types.ResourcesCapability() if advertised == "resources" else None,
401+
),
402+
server_info=types.Implementation(name="srv", version="1"),
403+
)
404+
mock_tool = mock.Mock(spec=types.Tool)
405+
mock_tool.name = "tool_a"
406+
mock_resource = mock.Mock(spec=types.Resource)
407+
mock_resource.name = "resource_b"
408+
mock_prompt = mock.Mock(spec=types.Prompt)
409+
mock_prompt.name = "prompt_c"
410+
mock_session.list_tools.return_value = mock.AsyncMock(tools=[mock_tool])
411+
mock_session.list_resources.return_value = mock.AsyncMock(resources=[mock_resource])
412+
mock_session.list_prompts.return_value = mock.AsyncMock(prompts=[mock_prompt])
413+
414+
group = ClientSessionGroup()
415+
await group.connect_with_session(types.Implementation(name="srv", version="1"), mock_session)
416+
417+
list_methods = {
418+
"tools": mock_session.list_tools,
419+
"prompts": mock_session.list_prompts,
420+
"resources": mock_session.list_resources,
421+
}
422+
for capability, list_method in list_methods.items():
423+
if capability == advertised:
424+
list_method.assert_awaited_once()
425+
else:
426+
list_method.assert_not_awaited()
427+
428+
assert group.tools == ({"tool_a": mock_tool} if advertised == "tools" else {})
429+
assert group.prompts == ({"prompt_c": mock_prompt} if advertised == "prompts" else {})
430+
assert group.resources == ({"resource_b": mock_resource} if advertised == "resources" else {})

0 commit comments

Comments
 (0)