From 86a3ae7ad7847f1092f2e033b1dab7f0080a2ed3 Mon Sep 17 00:00:00 2001 From: kigland Date: Mon, 26 Oct 2026 08:52:32 +0800 Subject: [PATCH 1/6] fix MCP list result parsing --- litellm/mcp_client/client.py | 18 +++++++++++++--- litellm/mcp_client/tools.py | 16 ++++++++------ tests/unit/mcp_client/test_mcp_client.py | 27 ++++++++++++++++++++++++ tests/unit/mcp_client/test_tools.py | 18 ++++++++++++++++ 4 files changed, 70 insertions(+), 9 deletions(-) diff --git a/litellm/mcp_client/client.py b/litellm/mcp_client/client.py index 107b7155303..bb9a88215e6 100644 --- a/litellm/mcp_client/client.py +++ b/litellm/mcp_client/client.py @@ -90,7 +90,7 @@ from mcp.types import ( from mcp.types import CallToolRequestParams as MCPCallToolRequestParams from mcp.types import CallToolResult as MCPCallToolResult from mcp.types import Tool as MCPTool -from pydantic import AnyUrl, TypeAdapter +from pydantic import AnyUrl, Field, TypeAdapter from litellm._logging import verbose_logger from litellm.constants import ( @@ -245,6 +245,14 @@ _ListPage = TypeVar("_ListPage", ListPromptsResult, ListResourcesResult, ListRes _ListItem = TypeVar("_ListItem") +class _LenientListPromptsResult(ListPromptsResult): + prompts: list[Prompt] = Field(default_factory=list) + + +class _LenientListResourcesResult(ListResourcesResult): + resources: list[Resource] = Field(default_factory=list) + + async def _run_bounded_cleanup(operation: Callable[[], Awaitable[TSessionResult]], deadline: float) -> TSessionResult: async def run() -> TSessionResult: with anyio.fail_after(max(0, deadline - anyio.current_time()), shield=True): @@ -1310,7 +1318,8 @@ class MCPClient: return ListPromptsResult(prompts=[]) try: items, freshness = await self._list_optional_pages( - lambda params: session.list_prompts(params=params), lambda page: page.prompts + lambda params: session.send_request(ListPromptsRequest(params=params), _LenientListPromptsResult), + lambda page: page.prompts, ) return ListPromptsResult(prompts=items, ttl_ms=freshness.ttl_ms, cache_scope=freshness.cache_scope) except MCPError as error: @@ -1423,7 +1432,10 @@ class MCPClient: return ListResourcesResult(resources=[]) try: items, freshness = await self._list_optional_pages( - lambda params: session.list_resources(params=params), lambda page: page.resources + lambda params: session.send_request( + ListResourcesRequest(params=params), _LenientListResourcesResult + ), + lambda page: page.resources, ) return ListResourcesResult(resources=items, ttl_ms=freshness.ttl_ms, cache_scope=freshness.cache_scope) except MCPError as error: diff --git a/litellm/mcp_client/tools.py b/litellm/mcp_client/tools.py index 4f204b3629e..61c560c0ae1 100644 --- a/litellm/mcp_client/tools.py +++ b/litellm/mcp_client/tools.py @@ -3,14 +3,14 @@ from typing import Final, Literal import anyio from mcp import ClientSession, MCPError -from mcp.types import INTERNAL_ERROR, PaginatedRequestParams +from mcp.types import INTERNAL_ERROR, ListToolsRequest, ListToolsResult, PaginatedRequestParams from mcp.types import CallToolRequestParams as MCPCallToolRequestParams from mcp.types import CallToolResult as MCPCallToolResult from mcp.types import Tool as MCPTool from openai.types.chat import ChatCompletionToolParam from openai.types.responses.function_tool_param import FunctionToolParam from openai.types.shared_params.function_definition import FunctionDefinition -from pydantic import TypeAdapter +from pydantic import Field, TypeAdapter from litellm._logging import verbose_logger from litellm.constants import ( @@ -24,6 +24,10 @@ from litellm.types.utils import ChatCompletionMessageToolCall _DECODED_JSON: Final = TypeAdapter(object) +class _LenientListToolsResult(ListToolsResult): + tools: list[MCPTool] = Field(default_factory=list) + + ######################################################## # List MCP Tool functions ######################################################## @@ -125,10 +129,10 @@ async def list_tools_with_pagination( with anyio.move_on_after(effective_deadline): for _ in range(MCP_TOOL_LISTING_MAX_PAGES): - result = ( - await session.list_tools() - if cursor is None - else await session.list_tools(params=PaginatedRequestParams(cursor=cursor)) + params = None if cursor is None else PaginatedRequestParams(cursor=cursor) + result = await session.send_request( + ListToolsRequest(params=params), + _LenientListToolsResult, ) tools.extend(result.tools) diff --git a/tests/unit/mcp_client/test_mcp_client.py b/tests/unit/mcp_client/test_mcp_client.py index 43021d248a9..7d0954b0e5d 100644 --- a/tests/unit/mcp_client/test_mcp_client.py +++ b/tests/unit/mcp_client/test_mcp_client.py @@ -78,6 +78,33 @@ from tests._support.clock_loops import AutojumpClockLoop, ManualClockLoop _JSONRPC_MESSAGE_ADAPTER: Final = TypeAdapter(JSONRPCMessage) +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("method_name", "result_type"), + [ + ("list_prompts", mcp_client_module._LenientListPromptsResult), + ("list_resources", mcp_client_module._LenientListResourcesResult), + ], +) +async def test_catalog_list_tolerates_missing_list_field(method_name: str, result_type: type[BaseModel]) -> None: + client = MCPClient(server_url="http://example.com/mcp", transport_type="http") + session = AsyncMock(spec=ClientSession) + session.server_capabilities = None + + async def send_request(_request, actual_result_type): + assert actual_result_type is result_type + return actual_result_type.model_validate({}) + + async def run_optional_discovery(operation): + return await operation(session) + + session.send_request.side_effect = send_request + client._run_optional_discovery = AsyncMock(side_effect=run_optional_discovery) + + assert await getattr(client, method_name)() == [] + session.send_request.assert_awaited_once() + + @pytest.mark.asyncio async def test_tool_continuation_reaches_modern_upstream() -> None: from mcp.types import ElicitResult, TextContent diff --git a/tests/unit/mcp_client/test_tools.py b/tests/unit/mcp_client/test_tools.py index ca3cf29bd90..039e57c2b69 100644 --- a/tests/unit/mcp_client/test_tools.py +++ b/tests/unit/mcp_client/test_tools.py @@ -39,6 +39,13 @@ def mock_session(): session = MagicMock() session.list_tools = AsyncMock() session.call_tool = AsyncMock() + + async def send_request(request, _result_type): + if request.params is not None: + return await session.list_tools(params=request.params) + return await session.list_tools() + + session.send_request = AsyncMock(side_effect=send_request) return session @@ -496,3 +503,14 @@ async def test_complete_discovery_preserves_tools_when_completeness_is_required( tool = MCPTool(name="complete", input_schema={"type": "object"}) mock_session.list_tools.return_value = ListToolsResult(tools=[tool]) assert await list_tools_with_pagination(mock_session, require_complete=True) == [tool] + + +@pytest.mark.asyncio +async def test_list_tools_tolerates_missing_tools_field(mock_session): + async def send_request(_request, result_type): + return result_type.model_validate({}) + + mock_session.send_request.side_effect = send_request + + assert await list_tools_with_pagination(mock_session) == [] + mock_session.send_request.assert_awaited_once() From c5619c94bfc17888de8b2b318d0cabbab546f42e Mon Sep 17 00:00:00 2001 From: kigland Date: Mon, 26 Oct 2026 09:01:32 +0800 Subject: [PATCH 2/6] fix(mcp): document mutable result fields --- litellm/mcp_client/client.py | 4 ++-- litellm/mcp_client/tools.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/litellm/mcp_client/client.py b/litellm/mcp_client/client.py index bb9a88215e6..f0560e7ca6b 100644 --- a/litellm/mcp_client/client.py +++ b/litellm/mcp_client/client.py @@ -246,11 +246,11 @@ _ListItem = TypeVar("_ListItem") class _LenientListPromptsResult(ListPromptsResult): - prompts: list[Prompt] = Field(default_factory=list) + prompts: list[Prompt] = Field(default_factory=list) # mutable-ok: MCP result model owns the decoded list class _LenientListResourcesResult(ListResourcesResult): - resources: list[Resource] = Field(default_factory=list) + resources: list[Resource] = Field(default_factory=list) # mutable-ok: MCP result model owns the decoded list async def _run_bounded_cleanup(operation: Callable[[], Awaitable[TSessionResult]], deadline: float) -> TSessionResult: diff --git a/litellm/mcp_client/tools.py b/litellm/mcp_client/tools.py index 61c560c0ae1..1c19425eae4 100644 --- a/litellm/mcp_client/tools.py +++ b/litellm/mcp_client/tools.py @@ -25,7 +25,7 @@ _DECODED_JSON: Final = TypeAdapter(object) class _LenientListToolsResult(ListToolsResult): - tools: list[MCPTool] = Field(default_factory=list) + tools: list[MCPTool] = Field(default_factory=list) # mutable-ok: MCP result model owns the decoded list ######################################################## From 01013b7e33cf7aa9ab735cc9a1d0da9c0621ca6f Mon Sep 17 00:00:00 2001 From: kigland Date: Mon, 26 Oct 2026 09:12:51 +0800 Subject: [PATCH 3/6] test(mcp): align pagination mocks with request path --- .../proxy/mcp_server/test_mcp_client_unit.py | 16 +++++++--------- .../unit/proxy/mcp_server/test_rest_endpoints.py | 6 +++--- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/tests/unit/proxy/mcp_server/test_mcp_client_unit.py b/tests/unit/proxy/mcp_server/test_mcp_client_unit.py index 1f804f3b425..7e4601211ef 100644 --- a/tests/unit/proxy/mcp_server/test_mcp_client_unit.py +++ b/tests/unit/proxy/mcp_server/test_mcp_client_unit.py @@ -186,16 +186,14 @@ class TestMCPClientUnitTests: }, ) ] - mock_result = MagicMock() - mock_result.tools = mock_tools - mock_session_instance.list_tools.return_value = mock_result + mock_session_instance.send_request.return_value = ListToolsResult(tools=mock_tools) client = MCPClient("http://example.com") result = await client.list_tools() assert result == mock_tools mock_session_instance.initialize.assert_called_once() - mock_session_instance.list_tools.assert_called_once() + mock_session_instance.send_request.assert_awaited_once() @pytest.mark.asyncio @patch.object(mcp_client_module, "streamable_http_client") # test-quality-ok: exercises MCPClient wiring; the walk itself is covered sessionless in test_tools.py @@ -229,7 +227,7 @@ class TestMCPClientUnitTests: description="Tool 100", inputSchema={}, ) - mock_session_instance.list_tools.side_effect = [ + mock_session_instance.send_request.side_effect = [ ListToolsResult(tools=first_page_tools, nextCursor="page-2"), ListToolsResult(tools=[second_page_tool]), ] @@ -238,8 +236,8 @@ class TestMCPClientUnitTests: result = await client.list_tools() assert result == [*first_page_tools, second_page_tool] - assert mock_session_instance.list_tools.call_count == 2 - second_call_params = mock_session_instance.list_tools.call_args_list[1].kwargs["params"] + assert mock_session_instance.send_request.await_count == 2 + second_call_params = mock_session_instance.send_request.await_args_list[1].args[0].params assert isinstance(second_call_params, PaginatedRequestParams) assert second_call_params.cursor == "page-2" @@ -267,7 +265,7 @@ class TestMCPClientUnitTests: ) mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_session_instance) - mock_session_instance.list_tools.side_effect = [ + mock_session_instance.send_request.side_effect = [ ListToolsResult( tools=[MCPTool(name="tool_0", description="Tool 0", inputSchema={})], nextCursor="page-2", @@ -279,7 +277,7 @@ class TestMCPClientUnitTests: result = await client.list_tools() assert result == [] - assert mock_session_instance.list_tools.call_count == 2 + assert mock_session_instance.send_request.await_count == 2 @pytest.mark.asyncio @patch.object(mcp_client_module, "streamable_http_client") diff --git a/tests/unit/proxy/mcp_server/test_rest_endpoints.py b/tests/unit/proxy/mcp_server/test_rest_endpoints.py index fdb1ff3cd09..e05dd71a515 100644 --- a/tests/unit/proxy/mcp_server/test_rest_endpoints.py +++ b/tests/unit/proxy/mcp_server/test_rest_endpoints.py @@ -1616,7 +1616,7 @@ class TestListToolsRestAPI: server_info=Implementation(name="stub", version="1"), ) ) - mock_session_instance.list_tools.side_effect = [ + mock_session_instance.send_request.side_effect = [ ListToolsResult( tools=[ MCPTool( @@ -1685,8 +1685,8 @@ class TestListToolsRestAPI: assert result["error"] is None assert result["message"] == "Successfully retrieved tools" - assert mock_session_instance.list_tools.call_count == 2 - second_call_params = mock_session_instance.list_tools.call_args_list[1].kwargs["params"] + assert mock_session_instance.send_request.await_count == 2 + second_call_params = mock_session_instance.send_request.await_args_list[1].args[0].params assert isinstance(second_call_params, PaginatedRequestParams) assert second_call_params.cursor == "page-2" From 4c5208015242fec69ccdd9ce70d0d91f5fb3bb6c Mon Sep 17 00:00:00 2001 From: kigland Date: Mon, 26 Oct 2026 10:41:46 +0800 Subject: [PATCH 4/6] preserve tool listing post-processing --- litellm/mcp_client/tools.py | 8 ++++++++ tests/unit/mcp_client/test_tools.py | 1 + 2 files changed, 9 insertions(+) diff --git a/litellm/mcp_client/tools.py b/litellm/mcp_client/tools.py index 1c19425eae4..b1faa829712 100644 --- a/litellm/mcp_client/tools.py +++ b/litellm/mcp_client/tools.py @@ -134,6 +134,14 @@ async def list_tools_with_pagination( ListToolsRequest(params=params), _LenientListToolsResult, ) + # ClientSession.list_tools() normally performs this post-processing + # after decoding: it filters invalid x-mcp-header annotations and + # records the derived header/output-schema state used by tools/call. + # Keep those semantics when injecting the lenient result model. + result = session._absorb_tool_listing( + result, + complete=cursor is None and result.next_cursor is None, + ) tools.extend(result.tools) next_cursor = result.next_cursor diff --git a/tests/unit/mcp_client/test_tools.py b/tests/unit/mcp_client/test_tools.py index 039e57c2b69..faec7315406 100644 --- a/tests/unit/mcp_client/test_tools.py +++ b/tests/unit/mcp_client/test_tools.py @@ -46,6 +46,7 @@ def mock_session(): return await session.list_tools() session.send_request = AsyncMock(side_effect=send_request) + session._absorb_tool_listing.side_effect = lambda result, **_kwargs: result return session From 930b1cfc585553351fd89df14c0abb29ac81bc5f Mon Sep 17 00:00:00 2001 From: kigland Date: Mon, 26 Oct 2026 13:34:06 +0800 Subject: [PATCH 5/6] satisfy type gate for tool listing --- litellm/mcp_client/tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/mcp_client/tools.py b/litellm/mcp_client/tools.py index b1faa829712..dc92a8b650d 100644 --- a/litellm/mcp_client/tools.py +++ b/litellm/mcp_client/tools.py @@ -138,7 +138,7 @@ async def list_tools_with_pagination( # after decoding: it filters invalid x-mcp-header annotations and # records the derived header/output-schema state used by tools/call. # Keep those semantics when injecting the lenient result model. - result = session._absorb_tool_listing( + result = session._absorb_tool_listing( # pyright: ignore[reportPrivateUsage] # no public equivalent result, complete=cursor is None and result.next_cursor is None, ) From e81a958e90f60df7ad52c8ca7d5204fa7b1e45f1 Mon Sep 17 00:00:00 2001 From: kigland Date: Mon, 26 Oct 2026 15:25:34 +0800 Subject: [PATCH 6/6] test(mcp): model listing post-processing in session mocks --- tests/unit/proxy/mcp_server/test_mcp_client_unit.py | 3 +++ tests/unit/proxy/mcp_server/test_rest_endpoints.py | 1 + 2 files changed, 4 insertions(+) diff --git a/tests/unit/proxy/mcp_server/test_mcp_client_unit.py b/tests/unit/proxy/mcp_server/test_mcp_client_unit.py index 7e4601211ef..110fdfac12d 100644 --- a/tests/unit/proxy/mcp_server/test_mcp_client_unit.py +++ b/tests/unit/proxy/mcp_server/test_mcp_client_unit.py @@ -187,6 +187,7 @@ class TestMCPClientUnitTests: ) ] mock_session_instance.send_request.return_value = ListToolsResult(tools=mock_tools) + mock_session_instance._absorb_tool_listing = MagicMock(side_effect=lambda result, **_kwargs: result) client = MCPClient("http://example.com") result = await client.list_tools() @@ -231,6 +232,7 @@ class TestMCPClientUnitTests: ListToolsResult(tools=first_page_tools, nextCursor="page-2"), ListToolsResult(tools=[second_page_tool]), ] + mock_session_instance._absorb_tool_listing = MagicMock(side_effect=lambda result, **_kwargs: result) client = MCPClient("http://example.com") result = await client.list_tools() @@ -272,6 +274,7 @@ class TestMCPClientUnitTests: ), RuntimeError("transient upstream failure"), ] + mock_session_instance._absorb_tool_listing = MagicMock(side_effect=lambda result, **_kwargs: result) client = MCPClient("http://example.com") result = await client.list_tools() diff --git a/tests/unit/proxy/mcp_server/test_rest_endpoints.py b/tests/unit/proxy/mcp_server/test_rest_endpoints.py index e05dd71a515..30bb4de615b 100644 --- a/tests/unit/proxy/mcp_server/test_rest_endpoints.py +++ b/tests/unit/proxy/mcp_server/test_rest_endpoints.py @@ -1637,6 +1637,7 @@ class TestListToolsRestAPI: ] ), ] + mock_session_instance._absorb_tool_listing = MagicMock(side_effect=lambda result, **_kwargs: result) mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_session_instance) mock_session_ctx.__aexit__ = AsyncMock(return_value=None) monkeypatch.setattr(