fix MCP list result parsing

This commit is contained in:
kigland 2026-10-26 08:52:32 +08:00
parent e32d8a4d03
commit 86a3ae7ad7
4 changed files with 70 additions and 9 deletions

View file

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

View file

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

View file

@ -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

View file

@ -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()