From 545bbeb001ac74f2c356fafacc3502c1011749a6 Mon Sep 17 00:00:00 2001 From: joshua Date: Fri, 18 Sep 2026 22:13:18 +0000 Subject: [PATCH] test(mcp): update MCP suites for SDK 2 APIs Rename McpError/isError/inputSchema-style references to the SDK 2 spellings, parse the JSONRPCMessage union with a TypeAdapter, and drive the SDK transports off httpx2 MockTransport injection where respx can no longer intercept. Adjust for SDK 2 behavior: the initialize handshake negotiates handshake-era protocol versions only, an empty SSE stream surfaces CONNECTION_CLOSED, non-2xx tool responses surface INTERNAL_ERROR MCPError instead of HTTPStatusError, and the SDK read timeout carries the JSON-RPC REQUEST_TIMEOUT code. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/mcp_tests/test_mcp_chat_completions.py | 10 +- tests/mcp_tests/test_mcp_client_unit.py | 8 +- tests/mcp_tests/test_mcp_logging.py | 14 +- tests/mcp_tests/test_mcp_server.py | 74 ++-- tests/mcp_tests/test_proxy_mcp_e2e.py | 28 +- .../test_semantic_tool_filter_e2e.py | 20 +- .../test_mcp_client.py | 368 +++++++++--------- .../experimental_mcp_client/test_tools.py | 40 +- .../mcp_server/faults/test_list_outcomes.py | 4 +- .../test_mcp_guardrail_handler.py | 44 +-- .../test_client_credentials.py | 41 +- .../outbound_credentials/test_httpx_auth.py | 12 +- .../outbound_credentials/test_resolver.py | 20 +- .../test_mcp_elicitation_handler.py | 8 +- .../mcp_server/test_mcp_env_vars.py | 6 +- .../test_mcp_metadata_preservation.py | 27 +- .../test_mcp_oauth_passthrough_tools.py | 2 +- .../mcp_server/test_mcp_proxy_mode.py | 14 +- .../test_mcp_sampling_completion_flow.py | 4 +- .../test_mcp_sampling_model_access.py | 24 +- .../test_mcp_sampling_response_conversion.py | 10 +- .../test_mcp_sampling_tool_conversion.py | 2 +- .../mcp_server/test_mcp_server.py | 110 +++--- .../mcp_server/test_mcp_server_manager.py | 163 ++++---- .../mcp_server/test_mcp_sigv4_auth.py | 24 +- .../mcp_server/test_mcp_tool_search.py | 56 +-- .../mcp_server/test_mcp_toolset_scope.py | 6 +- .../mcp_server/test_openapi_tool_auth.py | 8 +- .../mcp_server/test_rest_endpoints.py | 48 +-- .../mcp_server/test_semantic_tool_filter.py | 70 ++-- .../mcp_server/test_short_mcp_tool_prefix.py | 4 +- 31 files changed, 632 insertions(+), 637 deletions(-) diff --git a/tests/mcp_tests/test_mcp_chat_completions.py b/tests/mcp_tests/test_mcp_chat_completions.py index fbdbf9152aa..79619eefd7f 100644 --- a/tests/mcp_tests/test_mcp_chat_completions.py +++ b/tests/mcp_tests/test_mcp_chat_completions.py @@ -16,7 +16,7 @@ async def test_acompletion_mcp_auto_exec(monkeypatch): dummy_tool = SimpleNamespace( name="local_search", description="search", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, ) async def fake_process(user_api_key_auth, mcp_tools_with_litellm_proxy, **kwargs): @@ -92,7 +92,7 @@ async def test_acompletion_mcp_respects_manual_approval(monkeypatch): dummy_tool = SimpleNamespace( name="local_search", description="search", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, ) async def fake_process(user_api_key_auth, mcp_tools_with_litellm_proxy, **kwargs): @@ -167,7 +167,7 @@ async def test_completion_mcp_with_streaming_no_timeout_error(monkeypatch): dummy_tool = SimpleNamespace( name="local_search", description="search", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, ) async def fake_process(user_api_key_auth, mcp_tools_with_litellm_proxy, **kwargs): @@ -488,7 +488,7 @@ async def test_mcp_metadata_in_streaming_final_chunk(monkeypatch): dummy_tool = SimpleNamespace( name="local_search", description="search", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, ) async def fake_process(user_api_key_auth, mcp_tools_with_litellm_proxy, **kwargs): @@ -843,7 +843,7 @@ async def test_mcp_streaming_metadata_ordering(monkeypatch): dummy_tool = SimpleNamespace( name="local_search", description="search", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, ) async def fake_process(user_api_key_auth, mcp_tools_with_litellm_proxy, **kwargs): diff --git a/tests/mcp_tests/test_mcp_client_unit.py b/tests/mcp_tests/test_mcp_client_unit.py index 6438525706a..8e5a0cd30b9 100644 --- a/tests/mcp_tests/test_mcp_client_unit.py +++ b/tests/mcp_tests/test_mcp_client_unit.py @@ -169,7 +169,7 @@ class TestMCPClientUnitTests: MCPTool( name="test_tool", description="Test tool", - inputSchema={ + input_schema={ "type": "object", "properties": {"arg1": {"type": "string"}}, "required": ["arg1"], @@ -207,12 +207,12 @@ class TestMCPClientUnitTests: mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_session_instance) first_page_tools = [ - MCPTool(name=f"tool_{idx}", description=f"Tool {idx}", inputSchema={}) for idx in range(100) + MCPTool(name=f"tool_{idx}", description=f"Tool {idx}", input_schema={}) for idx in range(100) ] second_page_tool = MCPTool( name="tool_100", description="Tool 100", - inputSchema={}, + input_schema={}, ) mock_session_instance.list_tools.side_effect = [ ListToolsResult(tools=first_page_tools, nextCursor="page-2"), @@ -249,7 +249,7 @@ class TestMCPClientUnitTests: mock_session_instance.list_tools.side_effect = [ ListToolsResult( - tools=[MCPTool(name="tool_0", description="Tool 0", inputSchema={})], + tools=[MCPTool(name="tool_0", description="Tool 0", input_schema={})], nextCursor="page-2", ), RuntimeError("transient upstream failure"), diff --git a/tests/mcp_tests/test_mcp_logging.py b/tests/mcp_tests/test_mcp_logging.py index fc9f675f837..055b62a59f6 100644 --- a/tests/mcp_tests/test_mcp_logging.py +++ b/tests/mcp_tests/test_mcp_logging.py @@ -62,7 +62,7 @@ async def test_mcp_cost_tracking(): # Create a mock tool call result litellm.logging_callback_manager._reset_all_callbacks() mock_result = CallToolResult( - content=[TextContent(type="text", text="Test response")], isError=False + content=[TextContent(type="text", text="Test response")], is_error=False ) # Create a mock MCPClient @@ -73,7 +73,7 @@ async def test_mcp_cost_tracking(): MCPTool( name="add_tools", description="Test tool", - inputSchema={ + input_schema={ "type": "object", "properties": {"test": {"type": "string"}}, }, @@ -187,7 +187,7 @@ async def test_mcp_cost_tracking_per_tool(): # Create a mock tool call result litellm.logging_callback_manager._reset_all_callbacks() mock_result = CallToolResult( - content=[TextContent(type="text", text="Test response")], isError=False + content=[TextContent(type="text", text="Test response")], is_error=False ) # Create a mock MCPClient @@ -198,7 +198,7 @@ async def test_mcp_cost_tracking_per_tool(): MCPTool( name="expensive_tool", description="Expensive tool", - inputSchema={ + input_schema={ "type": "object", "properties": {"data": {"type": "string"}}, }, @@ -206,7 +206,7 @@ async def test_mcp_cost_tracking_per_tool(): MCPTool( name="cheap_tool", description="Cheap tool", - inputSchema={ + input_schema={ "type": "object", "properties": {"data": {"type": "string"}}, }, @@ -368,7 +368,7 @@ async def test_mcp_tool_call_hook(): # Create a mock tool call result litellm.logging_callback_manager._reset_all_callbacks() mock_result = CallToolResult( - content=[TextContent(type="text", text="Test response")], isError=False + content=[TextContent(type="text", text="Test response")], is_error=False ) # Create a mock MCPClient @@ -379,7 +379,7 @@ async def test_mcp_tool_call_hook(): MCPTool( name="add_tools", description="Test tool", - inputSchema={ + input_schema={ "type": "object", "properties": {"test": {"type": "string"}}, }, diff --git a/tests/mcp_tests/test_mcp_server.py b/tests/mcp_tests/test_mcp_server.py index 1781dfe2fc2..45be1f72207 100644 --- a/tests/mcp_tests/test_mcp_server.py +++ b/tests/mcp_tests/test_mcp_server.py @@ -44,7 +44,7 @@ async def test_mcp_server_manager_https_server(): MCPTool( name="gmail_send_email", description="Send an email via Gmail", - inputSchema={ + input_schema={ "type": "object", "properties": { "body": {"type": "string"}, @@ -58,7 +58,7 @@ async def test_mcp_server_manager_https_server(): mock_result = CallToolResult( content=[TextContent(type="text", text="Email sent successfully")], - isError=False, + is_error=False, ) # Create a mock MCPClient @@ -121,7 +121,7 @@ async def test_mcp_server_manager_https_server(): print("RESULT FROM CALLING TOOL FROM MCP SERVER MANAGER== ", result) # Verify result - assert result.isError is False + assert result.is_error is False assert len(result.content) == 1 assert isinstance(result.content[0], TextContent) assert result.content[0].text == "Email sent successfully" @@ -143,7 +143,7 @@ async def test_mcp_http_transport_list_tools_mock(): MCPTool( name="gmail_send_email", description="Send an email via Gmail", - inputSchema={ + input_schema={ "type": "object", "properties": { "to": {"type": "string"}, @@ -156,7 +156,7 @@ async def test_mcp_http_transport_list_tools_mock(): MCPTool( name="calendar_create_event", description="Create a calendar event", - inputSchema={ + input_schema={ "type": "object", "properties": { "title": {"type": "string"}, @@ -242,7 +242,7 @@ async def test_mcp_http_transport_call_tool_mock(): content=[ TextContent(type="text", text="Email sent successfully to test@example.com") ], - isError=False, + is_error=False, ) # Create a mock MCPClient that returns our test result @@ -288,7 +288,7 @@ async def test_mcp_http_transport_call_tool_mock(): ) # Assertions - assert result.isError is False + assert result.is_error is False assert len(result.content) == 1 # Type check before accessing text attribute assert isinstance(result.content[0], TextContent) @@ -308,7 +308,7 @@ async def test_mcp_http_transport_call_tool_error_mock(): # Mock tool call error result mock_error_result = CallToolResult( content=[TextContent(type="text", text="Error: Invalid email address")], - isError=True, + is_error=True, ) # Create a mock MCPClient that returns our test error result @@ -350,7 +350,7 @@ async def test_mcp_http_transport_call_tool_error_mock(): ) # Assertions for error case - assert result.isError is True + assert result.is_error is True assert len(result.content) == 1 # Type check before accessing text attribute assert isinstance(result.content[0], TextContent) @@ -796,7 +796,7 @@ async def test_list_tools_rest_api_success(): ListMCPToolsRestAPIResponseObject( name="test_tool", description="A test tool", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, mcp_info={"server_name": "test_server"}, ) ] @@ -892,8 +892,8 @@ async def test_get_tools_from_mcp_servers(): transport=MCPTransport.http, access_groups=["group-a"], ) - mock_tool_1 = MCPTool(name="tool1", description="test tool 1", inputSchema={}) - mock_tool_2 = MCPTool(name="tool2", description="test tool 2", inputSchema={}) + mock_tool_1 = MCPTool(name="tool1", description="test tool 1", input_schema={}) + mock_tool_2 = MCPTool(name="tool2", description="test tool 2", input_schema={}) # Test Case 1: With specific MCP servers try: @@ -1058,14 +1058,14 @@ async def test_list_tools_only_returns_allowed_servers(monkeypatch): MCPTool( name="send_email", description="Send an email via Server A", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ) ] mock_tools_b = [ MCPTool( name="create_event", description="Create an event via Server B", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ) ] @@ -1365,7 +1365,7 @@ async def test_mcp_server_manager_alias_tool_prefixing(): MCPTool( name="send_email", description="Send an email", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ) ] @@ -1425,7 +1425,7 @@ async def test_mcp_server_manager_server_name_tool_prefixing(): MCPTool( name="send_email", description="Send an email", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ) ] @@ -1485,7 +1485,7 @@ async def test_mcp_server_manager_server_id_tool_prefixing(): MCPTool( name="send_email", description="Send an email", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ) ] @@ -1904,12 +1904,12 @@ def test_create_tool_response_objects(): MCPTool( name="send_email", description="Send an email", - inputSchema={"type": "object", "properties": {"to": {"type": "string"}}}, + input_schema={"type": "object", "properties": {"to": {"type": "string"}}}, ), MCPTool( name="create_event", description="Create a calendar event", - inputSchema={"type": "object", "properties": {"title": {"type": "string"}}}, + input_schema={"type": "object", "properties": {"title": {"type": "string"}}}, ), ] @@ -1962,7 +1962,7 @@ async def test_get_tools_for_single_server(): MCPTool( name="send_email", description="Send an email", - inputSchema={"type": "object", "properties": {"to": {"type": "string"}}}, + input_schema={"type": "object", "properties": {"to": {"type": "string"}}}, ) ] @@ -2016,12 +2016,12 @@ async def test_get_tools_for_single_server_applies_disallowed_tools_without_allo MCPTool( name="send_email", description="Send an email", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), MCPTool( name="read_email", description="Read an email", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), ] @@ -2069,7 +2069,7 @@ async def test_rest_listing_hides_key_grants_dispatch_would_refuse(): MCPTool( name="read_wiki_contents", description="Read a wiki", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), ] @@ -2165,7 +2165,7 @@ async def test_list_tool_rest_api_with_server_specific_auth(): ListMCPToolsRestAPIResponseObject( name="send_email", description="Send an email", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, mcp_info={"server_name": "zapier"}, ) ] @@ -2259,7 +2259,7 @@ async def test_list_tool_rest_api_with_default_auth(): ListMCPToolsRestAPIResponseObject( name="send_email", description="Send an email", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, mcp_info={"server_name": "unknown_server"}, ) ] @@ -2371,7 +2371,7 @@ async def test_list_tool_rest_api_all_servers_with_auth(): ListMCPToolsRestAPIResponseObject( name="send_email", description="Send an email", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, mcp_info={"server_name": "zapier"}, ) ], @@ -2379,7 +2379,7 @@ async def test_list_tool_rest_api_all_servers_with_auth(): ListMCPToolsRestAPIResponseObject( name="send_message", description="Send a message", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, mcp_info={"server_name": "slack"}, ) ], @@ -2430,22 +2430,22 @@ async def test_filter_tools_by_allowed_tools_integration(): MCPTool( name="allowed_tool_1", description="This tool should be allowed", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), MCPTool( name="allowed_tool_2", description="This tool should also be allowed", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), MCPTool( name="blocked_tool_1", description="This tool should be blocked", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), MCPTool( name="blocked_tool_2", description="This tool should also be blocked", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), ] @@ -2545,22 +2545,22 @@ async def test_filter_tools_by_disallowed_tools_integration(): MCPTool( name="safe_tool_1", description="This tool should be allowed", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), MCPTool( name="safe_tool_2", description="This tool should also be allowed", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), MCPTool( name="dangerous_tool_1", description="This tool should be blocked", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), MCPTool( name="dangerous_tool_2", description="This tool should also be blocked", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), ] @@ -2659,12 +2659,12 @@ async def test_filter_tools_no_restrictions_integration(): MCPTool( name="tool_1", description="Tool 1", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), MCPTool( name="tool_2", description="Tool 2", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), ] diff --git a/tests/mcp_tests/test_proxy_mcp_e2e.py b/tests/mcp_tests/test_proxy_mcp_e2e.py index e1099fe0a62..88e2f43d07c 100644 --- a/tests/mcp_tests/test_proxy_mcp_e2e.py +++ b/tests/mcp_tests/test_proxy_mcp_e2e.py @@ -399,8 +399,8 @@ class TestProxyMcpSchemaDiscoveryMode: "arguments": {"a": 5, "b": 6}, }, ) - assert stdio.isError is False and stdio.content[0].text == "7" - assert http.isError is False and http.content[0].text == "111" + assert stdio.is_error is False and stdio.content[0].text == "7" + assert http.is_error is False and http.content[0].text == "111" @pytest.mark.asyncio async def test_server_scope_header_narrows_discovery(self, proxy_server_url: str) -> None: @@ -417,7 +417,7 @@ class TestProxyMcpSchemaDiscoveryMode: @pytest.mark.asyncio async def test_rejections_never_reach_upstream(self, proxy_server_url: str) -> None: - from mcp.shared.exceptions import McpError + from mcp.shared.exceptions import MCPError from mcp.types import METHOD_NOT_FOUND async with asyncio.timeout(30): @@ -430,22 +430,22 @@ class TestProxyMcpSchemaDiscoveryMode: bad_args = await session.call_tool( "call_tool", arguments={"tool_id": tool_id, "arguments": {"a": "three", "b": 4}} ) - assert bad_args.isError is True and "Invalid arguments" in bad_args.content[0].text + assert bad_args.is_error is True and "Invalid arguments" in bad_args.content[0].text stale = await session.call_tool("get_tool_schema", arguments={"tool_id": "0" * 32}) - assert stale.isError is True and "unauthorized tool_id" in stale.content[0].text + assert stale.is_error is True and "unauthorized tool_id" in stale.content[0].text for not_an_object in ("wrong", False): refused_args = await session.call_tool( "call_tool", arguments={"tool_id": tool_id, "arguments": not_an_object} ) - assert refused_args.isError is True and "object" in refused_args.content[0].text + assert refused_args.is_error is True and "object" in refused_args.content[0].text direct = await session.call_tool("math_stdio-add", arguments={"a": 1, "b": 2}) - assert direct.isError is True and "unavailable on /mcp/proxy" in direct.content[0].text + assert direct.is_error is True and "unavailable on /mcp/proxy" in direct.content[0].text for operation in (session.list_prompts, session.list_resources): - with pytest.raises(McpError) as refused: + with pytest.raises(MCPError) as refused: await operation() assert refused.value.error.code == METHOD_NOT_FOUND @@ -502,7 +502,7 @@ async def _scoped_session(url: str, key: str = "sk-1234", **headers: str) -> typ async def _search(session: ClientSession, query: str) -> dict[str, str]: result = await session.call_tool("search_tools", arguments={"query": query}) - assert result.isError is False, result + assert result.is_error is False, result return {hit["name"]: hit["tool_id"] for hit in _payload(result)} @@ -542,7 +542,7 @@ def _rpc_result(response: httpx.Response) -> dict[str, typing.Any]: def _assert_unauthorized(result: CallToolResult) -> None: - assert result.isError is True + assert result.is_error is True assert result.content[0].text == "Unknown or unauthorized tool_id" @@ -611,7 +611,7 @@ class TestProxyMcpAuthorizationScope: assert schema["name"] == name assert schema["tool_id"] == ids[name] result = await _call(session, ids[name]) - assert result.isError is False + assert result.is_error is False assert result.content[0].text == expected @pytest.mark.asyncio @@ -652,7 +652,7 @@ class TestProxyMcpAuthorizationScope: result = await session.call_tool( "call_tool", {"tool_id": ids[f"{name}-request_headers"], "arguments": {}} ) - assert result.isError is False + assert result.is_error is False assert _payload(result) == expected @pytest.mark.asyncio @@ -660,7 +660,7 @@ class TestProxyMcpAuthorizationScope: async with _scoped_session(proxy_server_url, "sk-restricted") as session: tool_id = (await _search(session, "add"))["math_restricted-add"] result = await _call(session, tool_id, 123, 456) - assert result.isError is False and result.content[0].text == "779" + assert result.is_error is False and result.content[0].text == "779" async with asyncio.timeout(10): while True: payload = json.loads(await asyncio.to_thread(proxy_call_recorder.events.get, True, 5)) @@ -714,7 +714,7 @@ class TestProxyMcpAuthorizationScope: hits = _payload(await handle_mcp_proxy_tool("search_tools", {"query": "add"}, auth)) tool_id = next(hit["tool_id"] for hit in hits if hit["name"] == "math_stdio-add") result = await handle_mcp_proxy_tool("call_tool", {"tool_id": tool_id, "arguments": arguments}, auth) - assert result.isError is True + assert result.is_error is True assert result.content[0].text == "arguments must be an object" asyncio.run_coroutine_threadsafe(check(), _proxy_server.loop).result(timeout=30) diff --git a/tests/mcp_tests/test_semantic_tool_filter_e2e.py b/tests/mcp_tests/test_semantic_tool_filter_e2e.py index aa25c98107e..d2ebdb3a4dd 100644 --- a/tests/mcp_tests/test_semantic_tool_filter_e2e.py +++ b/tests/mcp_tests/test_semantic_tool_filter_e2e.py @@ -58,46 +58,46 @@ async def test_e2e_semantic_filter(): MCPTool( name="gmail_send", description="Send an email via Gmail", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), MCPTool( name="calendar_create", description="Create a calendar event", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), MCPTool( name="file_upload", description="Upload a file", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), MCPTool( name="web_search", description="Search the web", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), MCPTool( name="slack_send", description="Send Slack message", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), MCPTool( - name="doc_read", description="Read document", inputSchema={"type": "object"} + name="doc_read", description="Read document", input_schema={"type": "object"} ), MCPTool( name="db_query", description="Query database", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), MCPTool( - name="api_call", description="Make API call", inputSchema={"type": "object"} + name="api_call", description="Make API call", input_schema={"type": "object"} ), MCPTool( name="task_create", description="Create task", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), MCPTool( - name="note_add", description="Add note", inputSchema={"type": "object"} + name="note_add", description="Add note", input_schema={"type": "object"} ), ] diff --git a/tests/test_litellm/experimental_mcp_client/test_mcp_client.py b/tests/test_litellm/experimental_mcp_client/test_mcp_client.py index cc647af865e..8c6d0cfbefd 100644 --- a/tests/test_litellm/experimental_mcp_client/test_mcp_client.py +++ b/tests/test_litellm/experimental_mcp_client/test_mcp_client.py @@ -4,22 +4,25 @@ import json import os import sys from collections.abc import AsyncIterator -from importlib import metadata from pathlib import Path from typing import Final from unittest.mock import AsyncMock, MagicMock, patch import anyio -import httpx +import httpx2 import pytest -import respx from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import StaticHeaderAuth -from mcp import McpError +from mcp import MCPError from mcp.client.streamable_http import streamable_http_client from pydantic import ValidationError from mcp.shared.message import SessionMessage +from mcp_types.version import LATEST_HANDSHAKE_VERSION +from pydantic import TypeAdapter from mcp.types import ( + CONNECTION_CLOSED, + INTERNAL_ERROR, LATEST_PROTOCOL_VERSION, + REQUEST_TIMEOUT, CallToolResult, ErrorData, Implementation, @@ -35,12 +38,10 @@ from mcp.types import ( import litellm.experimental_mcp_client.client as mcp_client_module from litellm.experimental_mcp_client.client import ( - MCP_STREAMABLE_HTTP_REQUIREMENT, MCPClient, _first_non_cancelled_cause, _TransportContext, as_mcp_read_timeout, - missing_streamable_http_client_error, strip_auth_scheme, ) from litellm.proxy._experimental.mcp_server.faults.list_outcomes import ( @@ -54,6 +55,21 @@ from litellm.types.mcp_server.mcp_server_manager import MCPServer from litellm.types.mcp import MCPAuth, MCPStdioConfig, MCPTransport +_JSONRPC_MESSAGE_ADAPTER: Final = TypeAdapter(JSONRPCMessage) + + +class _MockTransportClient(MCPClient): + """An MCPClient whose streamable-HTTP transport runs on an httpx2 MockTransport.""" + + def __init__(self, respond, **kwargs): + super().__init__(**kwargs) + self._respond = respond + + def _create_transport_context(self): + http_client = httpx2.AsyncClient(transport=httpx2.MockTransport(self._respond)) + return streamable_http_client(self.server_url, http_client=http_client), http_client + + class _FakeExceptionGroup(Exception): """Duck-typed stand-in for an anyio/builtin ExceptionGroup. @@ -171,14 +187,14 @@ class TestMCPClient: call_kwargs = mock_streamable_http_client.call_args[1] assert "http_client" in call_kwargs http_client = call_kwargs["http_client"] - assert isinstance(http_client, httpx.AsyncClient) + assert isinstance(http_client, httpx2.AsyncClient) # Test the factory still creates a client with proper SSL config httpx_factory = client._create_httpx_client_factory() test_client = httpx_factory(headers={"test": "header"}) assert test_client is not None - assert isinstance(test_client, httpx.AsyncClient) + assert isinstance(test_client, httpx2.AsyncClient) assert test_client.headers is not None await test_client.aclose() @@ -228,7 +244,7 @@ class TestMCPClient: # Verify the client was created successfully assert test_client is not None - assert isinstance(test_client, httpx.AsyncClient) + assert isinstance(test_client, httpx2.AsyncClient) # Verify it has the expected properties assert test_client.headers is not None # Clean up @@ -272,13 +288,13 @@ class TestMCPClient: call_kwargs = mock_streamable_http_client.call_args[1] assert "http_client" in call_kwargs http_client = call_kwargs["http_client"] - assert isinstance(http_client, httpx.AsyncClient) + assert isinstance(http_client, httpx2.AsyncClient) httpx_factory = client._create_httpx_client_factory() test_client = httpx_factory(headers={"test": "header"}) assert test_client is not None - assert isinstance(test_client, httpx.AsyncClient) + assert isinstance(test_client, httpx2.AsyncClient) assert test_client.headers is not None await test_client.aclose() @@ -460,12 +476,12 @@ class TestFirstNonCancelledCause: assert _first_non_cancelled_cause(asyncio.CancelledError()) is None def test_unwraps_group_to_non_cancelled_leaf(self): - target = httpx.ConnectError("refused") + target = httpx2.ConnectError("refused") group = _FakeExceptionGroup("g", [asyncio.CancelledError(), target]) assert _first_non_cancelled_cause(group) is target def test_unwraps_nested_group(self): - target = httpx.LocalProtocolError("Illegal header value") + target = httpx2.LocalProtocolError("Illegal header value") inner = _FakeExceptionGroup("inner", [asyncio.CancelledError(), target]) outer = _FakeExceptionGroup("outer", [asyncio.CancelledError(), inner]) assert _first_non_cancelled_cause(outer) is target @@ -476,7 +492,7 @@ class TestFirstNonCancelledCause: @pytest.mark.skipif(sys.version_info < (3, 11), reason="builtin ExceptionGroup requires 3.11+") def test_unwraps_builtin_exception_group(self): - target = httpx.ConnectError("refused") + target = httpx2.ConnectError("refused") group = ExceptionGroup("transport failed", [target]) # noqa: F821 assert _first_non_cancelled_cause(group) is target @@ -512,13 +528,13 @@ class TestExecuteSessionOperationSurfacesTransportError: mock_session_cls, AsyncMock(side_effect=asyncio.CancelledError("cancelled by group")), ) - connect_error = httpx.ConnectError("All connection attempts failed") + connect_error = httpx2.ConnectError("All connection attempts failed") transport_ctx = self._make_transport(_FakeExceptionGroup("transport", [connect_error])) async def _op(session): return "done" - with pytest.raises(httpx.ConnectError): + with pytest.raises(httpx2.ConnectError): await client._execute_session_operation(transport_ctx, _op) @pytest.mark.asyncio @@ -541,7 +557,7 @@ class TestExecuteSessionOperationSurfacesTransportError: init_result = MagicMock() init_result.instructions = None self._make_session(mock_session_cls, AsyncMock(return_value=init_result)) - transport_ctx = self._make_transport(_FakeExceptionGroup("late", [httpx.ConnectError("late cleanup error")])) + transport_ctx = self._make_transport(_FakeExceptionGroup("late", [httpx2.ConnectError("late cleanup error")])) async def _op(session): return "done" @@ -551,11 +567,11 @@ class TestExecuteSessionOperationSurfacesTransportError: class TestMCPClientResolvedAuth: - """A pre-resolved httpx.Auth is attached to the upstream client's auth= slot.""" + """A pre-resolved httpx2.Auth is attached to the upstream client's auth= slot.""" @pytest.mark.asyncio async def test_resolved_auth_feeds_the_auth_slot(self): - resolved = httpx.Auth() + resolved = httpx2.Auth() client = MCPClient(server_url="https://upstream.example.com", resolved_auth=resolved) http_client = client._create_httpx_client_factory()() try: @@ -565,11 +581,11 @@ class TestMCPClientResolvedAuth: @pytest.mark.asyncio async def test_resolved_auth_takes_precedence_over_aws_auth(self): - resolved = httpx.Auth() + resolved = httpx2.Auth() client = MCPClient( server_url="https://upstream.example.com", resolved_auth=resolved, - aws_auth=httpx.Auth(), + aws_auth=httpx2.Auth(), ) http_client = client._create_httpx_client_factory()() try: @@ -579,7 +595,7 @@ class TestMCPClientResolvedAuth: @pytest.mark.asyncio async def test_without_resolved_auth_falls_back_to_aws_auth(self): - aws = httpx.Auth() + aws = httpx2.Auth() client = MCPClient(server_url="https://upstream.example.com", aws_auth=aws) http_client = client._create_httpx_client_factory()() try: @@ -672,7 +688,7 @@ async def test_call_tool_raise_on_error_logs_at_debug_not_error(): with patch.object(client, "run_with_session", side_effect=_raise): with patch.object(mcp_client_module, "verbose_logger") as mock_log: result = await client.call_tool(params, raise_on_error=False) - assert result.isError is True + assert result.is_error is True assert mock_log.error.called, "swallow path must keep error-level visibility" @@ -766,15 +782,15 @@ class _ScriptedUpstream: return await self._task_group.__aexit__(None, None, None) async def _send(self, message): - await self._to_client_tx.send(SessionMessage(JSONRPCMessage(message))) + await self._to_client_tx.send(SessionMessage(message)) async def _serve(self): async for session_message in self._from_client_rx: - request = session_message.message.root + request = session_message.message method = getattr(request, "method", None) if method == "initialize": result = InitializeResult( - protocolVersion=LATEST_PROTOCOL_VERSION, + protocolVersion=LATEST_HANDSHAKE_VERSION, capabilities=ServerCapabilities(), serverInfo=Implementation(name="scripted-upstream", version="1.0.0"), ) @@ -835,36 +851,36 @@ async def test_upstream_json_rpc_error_408_is_not_reported_as_a_client_timeout() """The SDK reports its own elapsed read timeout and relays an upstream JSON-RPC error through the same exception class and the same numeric field, and JSON-RPC error codes are a different namespace from HTTP status codes. An upstream answering with application code 408 must keep - travelling as ``McpError`` so it is never blamed on the gateway as a 504. + travelling as ``MCPError`` so it is never blamed on the gateway as a 504. This is the other half of the pair: the same real transport and the same real session, so one mechanism pins both directions. """ client = _ScriptedClient( timeout=30, - tools_list_error=ErrorData(code=int(httpx.codes.REQUEST_TIMEOUT), message="re-authenticate and retry"), + tools_list_error=ErrorData(code=REQUEST_TIMEOUT, message="re-authenticate and retry"), ) - with pytest.raises(McpError) as exc_info: + with pytest.raises(MCPError) as exc_info: await asyncio.wait_for(client.list_tools(raise_on_error=True), timeout=10) assert not isinstance(exc_info.value, TimeoutError), "an upstream application error is not a gateway timeout" - assert exc_info.value.error.code == int(httpx.codes.REQUEST_TIMEOUT) + assert exc_info.value.error.code == REQUEST_TIMEOUT fault = classify_list_exception(exc_info.value) assert fault.tag != "timeout", "an upstream's own application error must never be reported as a gateway timeout" assert list_fault_http_status(fault) != 504 -def _raise_mcp_error_while_handling_a_timeout(code: int, message: str) -> McpError: - """An ``McpError`` carrying the context chain it would have if it were raised while a +def _raise_mcp_error_while_handling_a_timeout(code: int, message: str) -> MCPError: + """An ``MCPError`` carrying the context chain it would have if it were raised while a ``TimeoutError`` was in flight, which is how the SDK raises its own read timeout.""" try: try: raise TimeoutError() except TimeoutError: - raise McpError(ErrorData(code=code, message=message)) - except McpError as raised: + raise MCPError(code=code, message=message) + except MCPError as raised: return raised @@ -873,20 +889,20 @@ def test_as_mcp_read_timeout_separates_the_sdk_timeout_from_a_relayed_upstream_e upstream JSON-RPC error that happens to use 408, and the context chain alone cannot separate it from any other relayed error that surfaces while a timeout is being handled, so both must hold. """ - timeout_code = int(httpx.codes.REQUEST_TIMEOUT) + timeout_code = REQUEST_TIMEOUT translated = as_mcp_read_timeout(_raise_mcp_error_while_handling_a_timeout(timeout_code, "Timed out while waiting")) assert isinstance(translated, TimeoutError) assert str(translated) == "Timed out while waiting" - relayed_408 = McpError(ErrorData(code=timeout_code, message="upstream said 408")) + relayed_408 = MCPError(code=timeout_code, message="upstream said 408") assert as_mcp_read_timeout(relayed_408) is None, "an upstream 408 with no elapsed timeout is not our timeout" relayed_other = _raise_mcp_error_while_handling_a_timeout(-32603, "upstream internal error") assert as_mcp_read_timeout(relayed_other) is None, "a non-timeout code is not our timeout, whatever the chain" - assert as_mcp_read_timeout(McpError(ErrorData(code=-32603, message="boom"))) is None - assert as_mcp_read_timeout(RuntimeError("not an McpError")) is None + assert as_mcp_read_timeout(MCPError(code=-32603, message="boom")) is None + assert as_mcp_read_timeout(RuntimeError("not an MCPError")) is None @pytest.mark.asyncio @@ -1065,28 +1081,6 @@ def test_openapi_byok_auth_header_emits_exactly_one_scheme(auth_type, auth_value assert _format_byok_openapi_auth_header(server, auth_value) == expected -def test_missing_streamable_http_client_error_names_requirement_and_remedy(): - message = str(missing_streamable_http_client_error()) - - assert MCP_STREAMABLE_HTTP_REQUIREMENT in message - assert "pip install 'litellm[mcp]'" in message - assert metadata.version("mcp") in message - - -@pytest.mark.asyncio -async def test_http_transport_without_streamable_http_client_raises_actionable_import_error(): - client = MCPClient( - server_url="https://mcp-server.example.com", - transport_type=MCPTransport.http, - ) - - with patch.object( # test-quality-ok: simulates mcp<1.24.0 whose module lacks this import-time symbol - mcp_client_module, "streamable_http_client", None - ): - with pytest.raises(ImportError, match=r"pip install 'litellm\[mcp\]'"): - await client.list_tools(raise_on_error=True) - - def test_mcp_extra_matches_proxy_extra_and_supports_streamable_http(): try: import tomllib @@ -1099,20 +1093,20 @@ def test_mcp_extra_matches_proxy_extra_and_supports_streamable_http(): project = tomllib.load(f) extras = project["project"]["optional-dependencies"] - mcp_extra = extras["mcp"] - assert len(mcp_extra) == 1 + sdk2_names: Final = frozenset(("mcp", "httpx2", "pydantic")) + mcp_extra: Final = {Requirement(req).name: req for req in extras["mcp"]} + assert mcp_extra == { + name: req + for req in extras["proxy"] + if (name := Requirement(req).name) in sdk2_names + } - proxy_mcp_requirements = [req for req in extras["proxy"] if Requirement(req).name == "mcp"] - assert mcp_extra == proxy_mcp_requirements - assert mcp_extra == [req for req in project["dependency-groups"]["e2e-dev"] if Requirement(req).name == "mcp"] - - specifier = Requirement(mcp_extra[0]).specifier - assert not specifier.contains("1.23.0") - assert specifier.contains("1.28.1") - assert not specifier.contains("2.2.0") + specifier: Final = Requirement(mcp_extra["mcp"]).specifier + assert not specifier.contains("1.28.1") + assert specifier.contains("2.2.0") with (pyproject_path.parent / "uv.lock").open("rb") as f: locked = tomllib.load(f) - mcp_versions = [package["version"] for package in locked["package"] if package["name"] == "mcp"] + mcp_versions: Final = [package["version"] for package in locked["package"] if package["name"] == "mcp"] assert len(mcp_versions) == 1 assert specifier.contains(mcp_versions[0]) @@ -1196,11 +1190,11 @@ async def test_a_custom_credential_header_is_stripped_when_a_redirect_crosses_or """ seen: "list[tuple[str, str]]" = [] - def handler(request: httpx.Request) -> httpx.Response: + def handler(request: httpx2.Request) -> httpx2.Response: seen.append((request.url.host, request.headers.get("esb-oauth", ""))) if request.url.host == "upstream.example.com": - return httpx.Response(302, headers={"Location": "https://attacker.example.com/collect"}) - return httpx.Response(200) + return httpx2.Response(302, headers={"Location": "https://attacker.example.com/collect"}) + return httpx2.Response(200) client = MCPClient( server_url="https://upstream.example.com/mcp", @@ -1210,7 +1204,7 @@ async def test_a_custom_credential_header_is_stripped_when_a_redirect_crosses_or client.update_auth_value("minted-token") factory = client._create_httpx_client_factory() async with factory(headers=client._get_auth_headers(), timeout=None) as http_client: - http_client._transport = httpx.MockTransport(handler) + http_client._transport = httpx2.MockTransport(handler) await http_client.get("https://upstream.example.com/mcp") assert seen[0] == ("upstream.example.com", "Bearer minted-token") @@ -1288,7 +1282,7 @@ async def test_the_guard_agrees_with_httpx_about_authorization(start: str, targe """ seen: "list[tuple[str, str, str]]" = [] - def handler(request: httpx.Request) -> httpx.Response: + def handler(request: httpx2.Request) -> httpx2.Response: seen.append( ( str(request.url), @@ -1297,13 +1291,13 @@ async def test_the_guard_agrees_with_httpx_about_authorization(start: str, targe ) ) if str(request.url) == start: - return httpx.Response(302, headers={"Location": target}) - return httpx.Response(200) + return httpx2.Response(302, headers={"Location": target}) + return httpx2.Response(200) client = MCPClient(server_url=start, auth_type=MCPAuth.oauth2, auth_header_name="esb-oauth") factory = client._create_httpx_client_factory() async with factory(headers={"Authorization": "Bearer AUTH", "esb-oauth": "Bearer ESB"}, timeout=None) as http: - http._transport = httpx.MockTransport(handler) + http._transport = httpx2.MockTransport(handler) await http.get(start) _url, authorization, esb = seen[-1] @@ -1343,10 +1337,10 @@ async def test_invalid_http_response_surfaces_without_waiting_for_timeout( ) -> None: from litellm.proxy._experimental.mcp_server.rest_endpoints import _connection_error_message - def respond(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, headers={"Content-Type": content_type}, content=body) + def respond(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, headers={"Content-Type": content_type}, content=body) - async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client: + async with httpx2.AsyncClient(transport=httpx2.MockTransport(respond)) as http_client: client: Final = MCPClient(server_url="https://example.com/mcp", timeout=30) with pytest.raises(expected_type) as caught: await asyncio.wait_for( @@ -1366,24 +1360,24 @@ async def test_invalid_http_response_surfaces_without_waiting_for_timeout( @pytest.mark.asyncio @pytest.mark.parametrize("status_code", [200, 401, 503]) async def test_http_response_handler_preserves_success_and_http_errors(status_code: int) -> None: - def respond(request: httpx.Request) -> httpx.Response: + def respond(request: httpx2.Request) -> httpx2.Response: if request.method == "DELETE": - return httpx.Response(200) + return httpx2.Response(200) payload: Final = json.loads(request.content) if "id" not in payload: - return httpx.Response(202) + return httpx2.Response(202) result: Final = ( { - "protocolVersion": LATEST_PROTOCOL_VERSION, + "protocolVersion": payload["params"]["protocolVersion"], "capabilities": {}, "serverInfo": {"name": "test", "version": "1"}, } if payload["method"] == "initialize" else {"tools": []} ) - return httpx.Response(status_code, json={"jsonrpc": "2.0", "id": payload["id"], "result": result}) + return httpx2.Response(status_code, json={"jsonrpc": "2.0", "id": payload["id"], "result": result}) - async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client: + async with httpx2.AsyncClient(transport=httpx2.MockTransport(respond)) as http_client: client: Final = MCPClient(server_url="https://example.com/mcp", timeout=30) operation: Final = client._execute_session_operation( streamable_http_client(client.server_url, http_client=http_client), lambda session: session.list_tools() @@ -1392,9 +1386,9 @@ async def test_http_response_handler_preserves_success_and_http_errors(status_co result: Final = await asyncio.wait_for(operation, timeout=3) assert result.tools == [] else: - with pytest.raises(httpx.HTTPStatusError) as caught: + with pytest.raises(MCPError) as caught: await asyncio.wait_for(operation, timeout=3) - assert caught.value.response.status_code == status_code + assert caught.value.error.code == INTERNAL_ERROR @pytest.mark.asyncio @@ -1406,20 +1400,20 @@ async def test_http_response_handler_preserves_notifications_and_tool_listing() } logging_callback: Final = AsyncMock() - def respond(request: httpx.Request) -> httpx.Response: + def respond(request: httpx2.Request) -> httpx2.Response: if request.method == "DELETE": - return httpx.Response(200) + return httpx2.Response(200) payload: Final = json.loads(request.content) if "id" not in payload: - return httpx.Response(202) + return httpx2.Response(202) if payload["method"] == "initialize": - return httpx.Response( + return httpx2.Response( 200, json={ "jsonrpc": "2.0", "id": payload["id"], "result": { - "protocolVersion": LATEST_PROTOCOL_VERSION, + "protocolVersion": payload["params"]["protocolVersion"], "capabilities": {"logging": {}, "tools": {}}, "serverInfo": {"name": "test", "version": "1"}, }, @@ -1430,13 +1424,13 @@ async def test_http_response_handler_preserves_notifications_and_tool_listing() "id": payload["id"], "result": {"tools": [{"name": "search", "inputSchema": {"type": "object"}}]}, } - return httpx.Response( + return httpx2.Response( 200, headers={"Content-Type": "text/event-stream"}, content="".join(f"event: message\ndata: {json.dumps(message)}\n\n" for message in (notification, response)), ) - async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client: + async with httpx2.AsyncClient(transport=httpx2.MockTransport(respond)) as http_client: client: Final = MCPClient(server_url="https://example.com/mcp", timeout=30, logging_callback=logging_callback) result: Final = await asyncio.wait_for( client._execute_session_operation( @@ -1453,24 +1447,24 @@ async def test_http_response_handler_preserves_notifications_and_tool_listing() async def test_invalid_tool_list_schema_is_identified_as_an_upstream_response() -> None: from litellm.proxy._experimental.mcp_server.rest_endpoints import _connection_error_message - def respond(request: httpx.Request) -> httpx.Response: + def respond(request: httpx2.Request) -> httpx2.Response: if request.method == "DELETE": - return httpx.Response(200) + return httpx2.Response(200) payload: Final = json.loads(request.content) if "id" not in payload: - return httpx.Response(202) + return httpx2.Response(202) result: Final = ( { - "protocolVersion": LATEST_PROTOCOL_VERSION, + "protocolVersion": payload["params"]["protocolVersion"], "capabilities": {}, "serverInfo": {"name": "test", "version": "1"}, } if payload["method"] == "initialize" else {"tools": "secret-invalid-tools"} ) - return httpx.Response(200, json={"jsonrpc": "2.0", "id": payload["id"], "result": result}) + return httpx2.Response(200, json={"jsonrpc": "2.0", "id": payload["id"], "result": result}) - async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client: + async with httpx2.AsyncClient(transport=httpx2.MockTransport(respond)) as http_client: client: Final = MCPClient(server_url="https://example.com/mcp", timeout=30) with pytest.raises(ValidationError) as caught: await asyncio.wait_for( @@ -1486,7 +1480,7 @@ async def test_invalid_tool_list_schema_is_identified_as_an_upstream_response() assert "secret" not in message -class _DiagnosticSSEStream(httpx.AsyncByteStream): +class _DiagnosticSSEStream(httpx2.AsyncByteStream): def __init__(self, messages: asyncio.Queue[bytes | Exception | None]) -> None: self.messages = messages @@ -1543,26 +1537,26 @@ def _diagnostic_transport(transport: MCPTransport, mode: str, failure_method: st ) messages: Final[asyncio.Queue[bytes | Exception | None]] = asyncio.Queue() - async def respond(request: httpx.Request) -> httpx.Response: + async def respond(request: httpx2.Request) -> httpx2.Response: if request.method == "GET": - return httpx.Response( + return httpx2.Response( 200, headers={"Content-Type": "text/event-stream"}, stream=_DiagnosticSSEStream(messages) ) payload: Final = json.loads(request.content) if "method" not in payload or "id" not in payload: - return httpx.Response(202) + return httpx2.Response(202) if payload["method"] == failure_method and mode != "ok": if mode == "bad-json": await messages.put(b"secret-invalid-json") elif mode == "io-error": - await messages.put(httpx.ReadError("secret-read-error")) + await messages.put(httpx2.ReadError("secret-read-error")) elif mode == "closed": await messages.put(None) elif mode == "silent": await messages.put( b'{"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","data":"Waiting"}}' ) - return httpx.Response(202) + return httpx2.Response(202) if payload["method"] == "tools/list": for message in ( { @@ -1576,7 +1570,7 @@ def _diagnostic_transport(transport: MCPTransport, mode: str, failure_method: st await messages.put(json.dumps(message).encode()) result: Final = ( { - "protocolVersion": LATEST_PROTOCOL_VERSION, + "protocolVersion": payload["params"]["protocolVersion"], "capabilities": {"tools": {}, "logging": {}}, "serverInfo": {"name": "diagnostic", "version": "1"}, } @@ -1586,14 +1580,14 @@ def _diagnostic_transport(transport: MCPTransport, mode: str, failure_method: st else {"content": [{"type": "text", "text": "pong"}], "isError": False} ) await messages.put(json.dumps({"jsonrpc": "2.0", "id": payload["id"], "result": result}).encode()) - return httpx.Response(202) + return httpx2.Response(202) def factory( headers: dict[str, str] | None = None, - timeout: httpx.Timeout | None = None, - auth: httpx.Auth | None = None, - ) -> httpx.AsyncClient: - return httpx.AsyncClient(transport=httpx.MockTransport(respond), headers=headers, timeout=timeout, auth=auth) + timeout: httpx2.Timeout | None = None, + auth: httpx2.Auth | None = None, + ) -> httpx2.AsyncClient: + return httpx2.AsyncClient(transport=httpx2.MockTransport(respond), headers=headers, timeout=timeout, auth=auth) return sse_client("https://example.com/sse", httpx_client_factory=factory) @@ -1615,7 +1609,7 @@ async def test_transport_parsing_failure_is_preserved(transport: MCPTransport, f @pytest.mark.asyncio async def test_sse_read_failure_is_preserved() -> None: client: Final = MCPClient(server_url="https://example.com/sse", transport_type=MCPTransport.sse, timeout=0.2) - with pytest.raises(httpx.ReadError, match="secret-read-error"): + with pytest.raises(httpx2.ReadError, match="secret-read-error"): await asyncio.wait_for( client._execute_session_operation( _diagnostic_transport(MCPTransport.sse, "io-error", "tools/list"), lambda session: session.list_tools() @@ -1644,16 +1638,17 @@ async def test_transport_completion_and_normal_messages(transport: MCPTransport, pending: Final = client._execute_session_operation(_diagnostic_transport(transport, mode, "tools/list"), operation) if mode == "ok": result: Final = await asyncio.wait_for(pending, timeout=3) - assert result.isError is False + assert result.is_error is False assert result.content[0].text == "pong" logging_callback.assert_awaited_once_with(LoggingMessageNotificationParams(level="info", data="Listing tools")) else: - with pytest.raises(McpError) as caught: + with pytest.raises(MCPError) as caught: await asyncio.wait_for(pending, timeout=3) if mode == "closed": assert "connection was closed" in _connection_error_message(caught.value, client.server_url, 0.2) else: - assert isinstance(as_mcp_read_timeout(caught.value), TimeoutError) + assert caught.value.error.code == CONNECTION_CLOSED + assert "SSE stream ended" in caught.value.error.message @pytest.mark.asyncio @@ -1681,20 +1676,20 @@ async def test_transport_cancellation_cleans_up_a_pending_request(transport: MCP await asyncio.wait_for(task, timeout=3) -class _InterruptedHTTPBody(httpx.AsyncByteStream): +class _InterruptedHTTPBody(httpx2.AsyncByteStream): async def __aiter__(self) -> AsyncIterator[bytes]: yield b'{"jsonrpc":' - raise httpx.RemoteProtocolError("secret-incomplete-response") + raise httpx2.RemoteProtocolError("secret-incomplete-response") @pytest.mark.asyncio async def test_interrupted_http_response_preserves_the_transport_failure() -> None: - def respond(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, headers={"Content-Type": "application/json"}, stream=_InterruptedHTTPBody()) + def respond(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, headers={"Content-Type": "application/json"}, stream=_InterruptedHTTPBody()) - async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client: + async with httpx2.AsyncClient(transport=httpx2.MockTransport(respond)) as http_client: client: Final = MCPClient(server_url="https://example.com/mcp", timeout=30) - with pytest.raises(httpx.RemoteProtocolError, match="secret-incomplete-response"): + with pytest.raises(httpx2.RemoteProtocolError, match="secret-incomplete-response"): await asyncio.wait_for( client._execute_session_operation( streamable_http_client(client.server_url, http_client=http_client), @@ -1706,12 +1701,12 @@ async def test_interrupted_http_response_preserves_the_transport_failure() -> No @pytest.mark.asyncio async def test_empty_http_event_stream_uses_the_existing_request_deadline() -> None: - def respond(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, headers={"Content-Type": "text/event-stream"}, content=b"") + def respond(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, headers={"Content-Type": "text/event-stream"}, content=b"") - async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http_client: + async with httpx2.AsyncClient(transport=httpx2.MockTransport(respond)) as http_client: client: Final = MCPClient(server_url="https://example.com/mcp", timeout=0.2) - with pytest.raises(McpError) as caught: + with pytest.raises(MCPError) as caught: await asyncio.wait_for( client._execute_session_operation( streamable_http_client(client.server_url, http_client=http_client), @@ -1719,7 +1714,8 @@ async def test_empty_http_event_stream_uses_the_existing_request_deadline() -> N ), timeout=3, ) - assert isinstance(as_mcp_read_timeout(caught.value), TimeoutError) + assert caught.value.error.code == CONNECTION_CLOSED + assert "SSE stream ended" in caught.value.error.message @pytest.mark.asyncio @@ -1759,14 +1755,14 @@ async def test_optional_discovery_capabilities_and_errors( "resources/templates/list": {"name": "example", "uriTemplate": "test://{name}"}, }[method] - def respond(request: httpx.Request) -> httpx.Response: + def respond(request: httpx2.Request) -> httpx2.Response: if request.method == "DELETE": - return httpx.Response(200) - payload: Final = JSONRPCMessage.model_validate_json(request.content).root + return httpx2.Response(200) + payload: Final = _JSONRPC_MESSAGE_ADAPTER.validate_json(request.content) if not isinstance(payload, JSONRPCRequest): - return httpx.Response(202) + return httpx2.Response(202) if outcome == "initialize_not_found": - return httpx.Response( + return httpx2.Response( 200, json={ "jsonrpc": "2.0", @@ -1775,13 +1771,13 @@ async def test_optional_discovery_capabilities_and_errors( }, ) if payload.method == "initialize": - return httpx.Response( + return httpx2.Response( 200, json={ "jsonrpc": "2.0", "id": payload.id, "result": { - "protocolVersion": LATEST_PROTOCOL_VERSION, + "protocolVersion": payload.params["protocolVersion"], "capabilities": {} if outcome == "absent" else {advertised if outcome == "other_capability" else capability: {}}, @@ -1790,11 +1786,11 @@ async def test_optional_discovery_capabilities_and_errors( }, ) if outcome == "timeout": - raise httpx.ReadTimeout("Optional list timed out", request=request) + raise httpx2.ReadTimeout("Optional list timed out", request=request) if outcome == "unauthorized": - return httpx.Response(401) + return httpx2.Response(401) if outcome in ("method_not_found", "internal_error", "absent", "other_capability"): - return httpx.Response( + return httpx2.Response( 200, json={ "jsonrpc": "2.0", @@ -1805,26 +1801,24 @@ async def test_optional_discovery_capabilities_and_errors( }, }, ) - return httpx.Response(200, json={"jsonrpc": "2.0", "id": payload.id, "result": {field: [entry]}}) + return httpx2.Response(200, json={"jsonrpc": "2.0", "id": payload.id, "result": {field: [entry]}}) responder: Final = Mock(side_effect=respond) caplog.set_level(logging.DEBUG, logger="LiteLLM") - with respx.mock(base_url="https://example.com") as router: - router.route().mock(side_effect=responder) - client: Final = MCPClient(server_url="https://example.com/mcp") - operation: Final = { - "prompts/list": client.list_prompts, - "resources/list": client.list_resources, - "resources/templates/list": client.list_resource_templates, - }[method] - if raise_on_error and outcome in ("internal_error", "unauthorized", "timeout", "initialize_not_found"): - with pytest.raises((McpError, httpx.HTTPError)): - await operation(raise_on_error=True) - return - result: Final = await operation(raise_on_error=raise_on_error) + client: Final = _MockTransportClient(responder, server_url="https://example.com/mcp") + operation: Final = { + "prompts/list": client.list_prompts, + "resources/list": client.list_resources, + "resources/templates/list": client.list_resource_templates, + }[method] + if raise_on_error and outcome in ("internal_error", "unauthorized", "timeout", "initialize_not_found"): + with pytest.raises((MCPError, httpx2.HTTPError)): + await operation(raise_on_error=True) + return + result: Final = await operation(raise_on_error=raise_on_error) requests: Final = tuple( - JSONRPCMessage.model_validate_json(call.args[0].content).root + _JSONRPC_MESSAGE_ADAPTER.validate_json(call.args[0].content) for call in responder.call_args_list if call.args[0].method == "POST" ) @@ -1853,34 +1847,32 @@ async def test_optional_discovery_uses_each_sessions_capabilities(supports_first capabilities: Final = iter(({"resources": {}}, {}) if supports_first else ({}, {"resources": {}})) - def respond(request: httpx.Request) -> httpx.Response: + def respond(request: httpx2.Request) -> httpx2.Response: if request.method == "DELETE": - return httpx.Response(200) - payload: Final = JSONRPCMessage.model_validate_json(request.content).root + return httpx2.Response(200) + payload: Final = _JSONRPC_MESSAGE_ADAPTER.validate_json(request.content) if not isinstance(payload, JSONRPCRequest): - return httpx.Response(202) + return httpx2.Response(202) result: Final = ( { - "protocolVersion": LATEST_PROTOCOL_VERSION, + "protocolVersion": payload.params["protocolVersion"], "capabilities": next(capabilities), "serverInfo": {"name": "changing", "version": "1"}, } if payload.method == "initialize" else {"resources": [{"name": "example", "uri": "test://example"}]} ) - return httpx.Response(200, json={"jsonrpc": "2.0", "id": payload.id, "result": result}) + return httpx2.Response(200, json={"jsonrpc": "2.0", "id": payload.id, "result": result}) responder: Final = Mock(side_effect=respond) - with respx.mock(base_url="https://example.com") as router: - router.route().mock(side_effect=responder) - client: Final = MCPClient(server_url="https://example.com/mcp") - first: Final = await client.list_resources() - second: Final = await client.list_resources() + client: Final = _MockTransportClient(responder, server_url="https://example.com/mcp") + first: Final = await client.list_resources() + second: Final = await client.list_resources() assert [item.name for item in first] == (["example"] if supports_first else []) assert [item.name for item in second] == ([] if supports_first else ["example"]) requests: Final = tuple( - JSONRPCMessage.model_validate_json(call.args[0].content).root + _JSONRPC_MESSAGE_ADAPTER.validate_json(call.args[0].content) for call in responder.call_args_list if call.args[0].method == "POST" ) @@ -1895,20 +1887,20 @@ async def test_optional_discovery_preserves_cancellation(method: str) -> None: ready: Final = asyncio.Event() pending: Final = asyncio.Event() - async def respond(request: httpx.Request) -> httpx.Response: + async def respond(request: httpx2.Request) -> httpx2.Response: if request.method == "DELETE": - return httpx.Response(200) - payload: Final = JSONRPCMessage.model_validate_json(request.content).root + return httpx2.Response(200) + payload: Final = _JSONRPC_MESSAGE_ADAPTER.validate_json(request.content) if not isinstance(payload, JSONRPCRequest): - return httpx.Response(202) + return httpx2.Response(202) if payload.method == "initialize": - return httpx.Response( + return httpx2.Response( 200, json={ "jsonrpc": "2.0", "id": payload.id, "result": { - "protocolVersion": LATEST_PROTOCOL_VERSION, + "protocolVersion": payload.params["protocolVersion"], "capabilities": {"resources": {}, "prompts": {}}, "serverInfo": {"name": "pending", "version": "1"}, }, @@ -1916,23 +1908,21 @@ async def test_optional_discovery_preserves_cancellation(method: str) -> None: ) ready.set() await pending.wait() - return httpx.Response(202) + return httpx2.Response(202) - with respx.mock(base_url="https://example.com") as router: - router.route().mock(side_effect=respond) - client: Final = MCPClient(server_url="https://example.com/mcp") - operation: Final = { - "prompts/list": client.list_prompts, - "resources/list": client.list_resources, - "resources/templates/list": client.list_resource_templates, - }[method] - task: Final = asyncio.create_task(operation()) - try: - await asyncio.wait_for(ready.wait(), timeout=3) - finally: - task.cancel() - with pytest.raises(asyncio.CancelledError): - await asyncio.wait_for(task, timeout=3) + client: Final = _MockTransportClient(respond, server_url="https://example.com/mcp") + operation: Final = { + "prompts/list": client.list_prompts, + "resources/list": client.list_resources, + "resources/templates/list": client.list_resource_templates, + }[method] + task: Final = asyncio.create_task(operation()) + try: + await asyncio.wait_for(ready.wait(), timeout=3) + finally: + task.cancel() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, timeout=3) diff --git a/tests/test_litellm/experimental_mcp_client/test_tools.py b/tests/test_litellm/experimental_mcp_client/test_tools.py index 6645b06664d..55eccbb8fbf 100644 --- a/tests/test_litellm/experimental_mcp_client/test_tools.py +++ b/tests/test_litellm/experimental_mcp_client/test_tools.py @@ -32,7 +32,7 @@ def mock_mcp_tool(): return MCPTool( name="test_tool", description="A test tool", - inputSchema={"type": "object", "properties": {"test": {"type": "string"}}}, + input_schema={"type": "object", "properties": {"test": {"type": "string"}}}, ) @@ -51,7 +51,7 @@ def mock_list_tools_result(): MCPTool( name="test_tool", description="A test tool", - inputSchema={ + input_schema={ "type": "object", "properties": {"test": {"type": "string"}}, }, @@ -113,12 +113,12 @@ async def test_load_mcp_tools_follows_pagination(mock_session): mock_session.list_tools.side_effect = [ ListToolsResult( tools=[ - MCPTool(name="tool_a", description="a", inputSchema={}), - MCPTool(name="tool_b", description="b", inputSchema={}), + MCPTool(name="tool_a", description="a", input_schema={}), + MCPTool(name="tool_b", description="b", input_schema={}), ], nextCursor="page-2", ), - ListToolsResult(tools=[MCPTool(name="tool_c", description="c", inputSchema={})]), + ListToolsResult(tools=[MCPTool(name="tool_c", description="c", input_schema={})]), ] result = await load_mcp_tools(mock_session, format="mcp") assert [tool.name for tool in result] == ["tool_a", "tool_b", "tool_c"] @@ -133,14 +133,14 @@ async def test_pagination_walk_stops_at_page_cap(mock_session, monkeypatch): monkeypatch.setattr("litellm.experimental_mcp_client.tools.MCP_TOOL_LISTING_MAX_PAGES", 2) mock_session.list_tools.side_effect = [ ListToolsResult( - tools=[MCPTool(name="tool_0", description="0", inputSchema={})], + tools=[MCPTool(name="tool_0", description="0", input_schema={})], nextCursor="page-2", ), ListToolsResult( - tools=[MCPTool(name="tool_1", description="1", inputSchema={})], + tools=[MCPTool(name="tool_1", description="1", input_schema={})], nextCursor="page-3", ), - ListToolsResult(tools=[MCPTool(name="tool_2", description="2", inputSchema={})]), + ListToolsResult(tools=[MCPTool(name="tool_2", description="2", input_schema={})]), ] result = await list_tools_with_pagination(mock_session) assert [tool.name for tool in result] == ["tool_0", "tool_1"] @@ -151,11 +151,11 @@ async def test_pagination_walk_stops_at_page_cap(mock_session, monkeypatch): async def test_pagination_walk_stops_on_repeated_cursor(mock_session): mock_session.list_tools.side_effect = [ ListToolsResult( - tools=[MCPTool(name="tool_0", description="0", inputSchema={})], + tools=[MCPTool(name="tool_0", description="0", input_schema={})], nextCursor="same-cursor", ), ListToolsResult( - tools=[MCPTool(name="tool_1", description="1", inputSchema={})], + tools=[MCPTool(name="tool_1", description="1", input_schema={})], nextCursor="same-cursor", ), ] @@ -168,7 +168,7 @@ async def test_pagination_walk_stops_on_repeated_cursor(mock_session): async def test_pagination_walk_treats_empty_cursor_as_terminal(mock_session): mock_session.list_tools.side_effect = [ ListToolsResult( - tools=[MCPTool(name="tool_0", description="0", inputSchema={})], + tools=[MCPTool(name="tool_0", description="0", input_schema={})], nextCursor="", ), ] @@ -190,7 +190,7 @@ async def test_pagination_walk_stops_at_whole_walk_deadline(mock_session, monkey await anyio.sleep(0.15) idx = int(params.cursor) if params is not None else 0 return ListToolsResult( - tools=[MCPTool(name=f"tool_{idx}", description=str(idx), inputSchema={})], + tools=[MCPTool(name=f"tool_{idx}", description=str(idx), input_schema={})], nextCursor=str(idx + 1), ) @@ -212,7 +212,7 @@ async def test_pagination_walk_honors_explicit_deadline_over_globals(mock_sessio async def slow_page(params=None): await anyio.sleep(0.15) idx = int(params.cursor) if params is not None else 0 - tools = [MCPTool(name=f"tool_{idx}", description=str(idx), inputSchema={})] + tools = [MCPTool(name=f"tool_{idx}", description=str(idx), input_schema={})] if idx == 0: return ListToolsResult(tools=tools, nextCursor="1") return ListToolsResult(tools=tools) @@ -227,10 +227,10 @@ async def test_pagination_walk_honors_explicit_deadline_over_globals(mock_sessio async def test_load_mcp_tools_openai_format_spans_pages(mock_session): mock_session.list_tools.side_effect = [ ListToolsResult( - tools=[MCPTool(name="tool_a", description="a", inputSchema={})], + tools=[MCPTool(name="tool_a", description="a", input_schema={})], nextCursor="page-2", ), - ListToolsResult(tools=[MCPTool(name="tool_b", description="b", inputSchema={})]), + ListToolsResult(tools=[MCPTool(name="tool_b", description="b", input_schema={})]), ] result = await load_mcp_tools(mock_session, format="openai") assert [t["function"]["name"] for t in result] == ["tool_a", "tool_b"] @@ -349,7 +349,7 @@ def test_transform_mcp_tool_to_openai_responses_api_tool(): minimal_tool = MCPTool( name="GitMCP-fetch_litellm_documentation", description="Fetch entire documentation file from GitHub repository", - inputSchema={"type": "object"}, # This was causing the error + input_schema={"type": "object"}, # This was causing the error ) openai_tool = transform_mcp_tool_to_openai_responses_api_tool(minimal_tool) @@ -364,7 +364,7 @@ def test_transform_mcp_tool_to_openai_responses_api_tool(): complete_tool = MCPTool( name="test_tool_complete", description="A test tool with complete schema", - inputSchema={ + input_schema={ "type": "object", "properties": {"query": {"type": "string", "description": "Search query"}}, "required": ["query"], @@ -395,7 +395,7 @@ def test_transform_mcp_tool_to_anthropic_tool(): tool = MCPTool( name="read_wiki_structure", description="Get a list of documentation topics", - inputSchema={ + input_schema={ "type": "object", "properties": {"repoName": {"type": "string"}}, "required": ["repoName"], @@ -417,7 +417,7 @@ def test_transform_mcp_tool_to_anthropic_tool(): def test_transform_mcp_tool_to_anthropic_tool_normalizes_empty_schema(): """A tool with no declared arguments must still present a valid object schema.""" anthropic_tool = transform_mcp_tool_to_anthropic_tool( - MCPTool(name="noargs", description=None, inputSchema={}) + MCPTool(name="noargs", description=None, input_schema={}) ) assert anthropic_tool["name"] == "noargs" @@ -445,7 +445,7 @@ def test_transform_mcp_tool_to_anthropic_tool_strips_keys_anthropic_rejects(): tool = MCPTool( name="rich", description="tool with a dirty schema", - inputSchema={ + input_schema={ "type": "object", "properties": {"q": {"type": "string"}}, "required": ["q"], diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/faults/test_list_outcomes.py b/tests/test_litellm/proxy/_experimental/mcp_server/faults/test_list_outcomes.py index 65e2faee1b2..f951499e18f 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/faults/test_list_outcomes.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/faults/test_list_outcomes.py @@ -9,7 +9,7 @@ if sys.version_info < (3, 11): # BaseExceptionGroup is a builtin only from 3.11 import httpx import pytest -from mcp import McpError +from mcp import MCPError from mcp.types import ErrorData from litellm.proxy._experimental.mcp_server.exceptions import ( @@ -45,7 +45,7 @@ def test_upstream_json_rpc_error_code_is_never_read_as_an_http_status(): to answer with application code 408. Classifying that number as a gateway timeout would report a 504 the gateway never caused. A client timeout reaches here already expressed as a ``TimeoutError``, so this taxonomy never has to read the code to tell them apart.""" - upstream_error = McpError(ErrorData(code=int(httpx.codes.REQUEST_TIMEOUT), message="re-authenticate and retry")) + upstream_error = MCPError(code=int(httpx.codes.REQUEST_TIMEOUT), message="re-authenticate and retry") assert classify_list_exception(upstream_error).tag != "timeout" assert list_fault_http_status(classify_list_exception(upstream_error)) != 504 diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/guardrail_translation/test_mcp_guardrail_handler.py b/tests/test_litellm/proxy/_experimental/mcp_server/guardrail_translation/test_mcp_guardrail_handler.py index 28959054195..9dd88ff18bd 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/guardrail_translation/test_mcp_guardrail_handler.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/guardrail_translation/test_mcp_guardrail_handler.py @@ -533,7 +533,7 @@ async def test_process_output_response_masks_text_content(): TextContent(type="text", text="email jane@example.com"), TextContent(type="text", text="call 415-555-0132"), ], - isError=False, + is_error=False, ) returned = await handler.process_output_response( @@ -569,7 +569,7 @@ async def test_process_output_response_propagates_block(): guardrail = MaskingGuardrail( raises=BlockedPiiEntityError(entity_type="EMAIL_ADDRESS", guardrail_name="masking-mcp-guardrail") ) - result = CallToolResult(content=[TextContent(type="text", text="jane@example.com")], isError=False) + result = CallToolResult(content=[TextContent(type="text", text="jane@example.com")], is_error=False) with pytest.raises(BlockedPiiEntityError): await handler.process_output_response(response=result, guardrail_to_apply=guardrail) @@ -582,7 +582,7 @@ async def test_process_output_response_skips_non_text_content(): guardrail = MaskingGuardrail(masked_texts=["should not be used"]) result = CallToolResult( content=[ImageContent(type="image", data="aGk=", mimeType="image/png")], - isError=False, + is_error=False, ) returned = await handler.process_output_response(response=result, guardrail_to_apply=guardrail) @@ -613,7 +613,7 @@ async def test_process_output_response_blocks_on_text_count_mismatch(): TextContent(type="text", text="jane@example.com"), TextContent(type="text", text="415-555-0132"), ], - isError=False, + is_error=False, ) with pytest.raises(HTTPException) as exc_info: @@ -645,14 +645,14 @@ async def test_structured_content_is_masked_alongside_content(): guardrail = SubstitutingGuardrail("jane@example.com", "") response = CallToolResult( content=[TextContent(type="text", text="email jane@example.com")], - structuredContent={"contact": {"email": "jane@example.com"}, "balance": 42.0}, - isError=False, + structured_content={"contact": {"email": "jane@example.com"}, "balance": 42.0}, + is_error=False, ) returned = await handler.process_output_response(response=response, guardrail_to_apply=guardrail) assert returned.content[0].text == "email " - assert returned.structuredContent == {"contact": {"email": ""}, "balance": 42.0} + assert returned.structured_content== {"contact": {"email": ""}, "balance": 42.0} @pytest.mark.asyncio @@ -666,14 +666,14 @@ async def test_value_present_only_in_structured_content_is_masked(): guardrail = SubstitutingGuardrail("jane@example.com", "") response = CallToolResult( content=[TextContent(type="text", text="lookup complete")], - structuredContent={"records": [{"email": "jane@example.com"}]}, - isError=False, + structured_content={"records": [{"email": "jane@example.com"}]}, + is_error=False, ) returned = await handler.process_output_response(response=response, guardrail_to_apply=guardrail) assert "jane@example.com" in guardrail.seen_texts - assert returned.structuredContent == {"records": [{"email": ""}]} + assert returned.structured_content== {"records": [{"email": ""}]} assert returned.content[0].text == "lookup complete" @@ -684,13 +684,13 @@ async def test_structured_content_without_a_match_is_untouched(): guardrail = SubstitutingGuardrail("jane@example.com", "") response = CallToolResult( content=[TextContent(type="text", text="lookup complete")], - structuredContent={"record_id": "C-1001", "balance": 42.0, "active": True, "note": None}, - isError=False, + structured_content={"record_id": "C-1001", "balance": 42.0, "active": True, "note": None}, + is_error=False, ) returned = await handler.process_output_response(response=response, guardrail_to_apply=guardrail) - assert returned.structuredContent == {"record_id": "C-1001", "balance": 42.0, "active": True, "note": None} + assert returned.structured_content== {"record_id": "C-1001", "balance": 42.0, "active": True, "note": None} @pytest.mark.asyncio @@ -707,8 +707,8 @@ async def test_structured_content_nested_too_deeply_is_blocked(): nested = {"next": nested} response = CallToolResult( content=[TextContent(type="text", text="lookup complete")], - structuredContent=nested, - isError=False, + structured_content=nested, + is_error=False, ) with pytest.raises(HTTPException) as exc_info: @@ -754,8 +754,8 @@ async def test_sensitive_structured_content_key_is_blocked(): guardrail = SubstitutingGuardrail("jane@example.com", "") response = CallToolResult( content=[TextContent(type="text", text="lookup complete")], - structuredContent={"jane@example.com": {"balance": 42.0}}, - isError=False, + structured_content={"jane@example.com": {"balance": 42.0}}, + is_error=False, ) with pytest.raises(HTTPException) as exc_info: @@ -774,8 +774,8 @@ async def test_sensitive_structured_content_numeric_value_is_blocked(): guardrail = SubstitutingGuardrail("4155550199", "") response = CallToolResult( content=[TextContent(type="text", text="lookup complete")], - structuredContent={"phone": 4155550199}, - isError=False, + structured_content={"phone": 4155550199}, + is_error=False, ) with pytest.raises(HTTPException) as exc_info: @@ -791,11 +791,11 @@ async def test_clean_structured_content_keys_do_not_block(): guardrail = SubstitutingGuardrail("jane@example.com", "") response = CallToolResult( content=[TextContent(type="text", text="email jane@example.com")], - structuredContent={"record_id": "C-1001", "balance": 42.0, "count": 3}, - isError=False, + structured_content={"record_id": "C-1001", "balance": 42.0, "count": 3}, + is_error=False, ) returned = await handler.process_output_response(response=response, guardrail_to_apply=guardrail) assert returned.content[0].text == "email " - assert returned.structuredContent == {"record_id": "C-1001", "balance": 42.0, "count": 3} + assert returned.structured_content== {"record_id": "C-1001", "balance": 42.0, "count": 3} diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_client_credentials.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_client_credentials.py index 774cd022703..1cad9a1fccb 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_client_credentials.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_client_credentials.py @@ -6,6 +6,7 @@ rotation-aware cache keying, expires_in-driven expiry, error classification, and """ import httpx +import httpx2 import pytest from pydantic import SecretStr @@ -322,27 +323,27 @@ async def test_refetch_returns_none_when_the_grant_fails(): assert await source.refetch("s", _config(), failed_access_token="stale") is None -def _upstream(responses: "list[httpx.Response]") -> "tuple[httpx.MockTransport, list[str]]": +def _upstream(responses: "list[httpx2.Response]") -> "tuple[httpx2.MockTransport, list[str]]": # The auth flow re-yields the same Request object on retry, so snapshot the Authorization # value per send; holding the Request would show the post-retry mutation for both entries. seen: "list[str]" = [] - def handler(request: httpx.Request) -> httpx.Response: + def handler(request: httpx2.Request) -> httpx2.Response: seen.append(request.headers.get("Authorization", "")) return responses[min(len(seen) - 1, len(responses) - 1)] - return httpx.MockTransport(handler), seen + return httpx2.MockTransport(handler), seen @pytest.mark.asyncio async def test_bearer_auth_sends_the_token_and_leaves_a_success_alone(): - transport, seen = _upstream([httpx.Response(200)]) + transport, seen = _upstream([httpx2.Response(200)]) async def refetch(failed: str) -> "str | None": raise AssertionError("must not refetch on success") auth = ClientCredentialsBearerAuth("m2m-token", refetch, ClientCredentialsConfig()) - async with httpx.AsyncClient(transport=transport, auth=auth) as client: + async with httpx2.AsyncClient(transport=transport, auth=auth) as client: response = await client.get("https://upstream.example.com/mcp") assert response.status_code == 200 assert seen == ["Bearer m2m-token"] @@ -350,7 +351,7 @@ async def test_bearer_auth_sends_the_token_and_leaves_a_success_alone(): @pytest.mark.asyncio async def test_bearer_auth_retries_a_401_once_with_a_fresh_token(): - transport, seen = _upstream([httpx.Response(401), httpx.Response(200)]) + transport, seen = _upstream([httpx2.Response(401), httpx2.Response(200)]) refetched: "list[str]" = [] async def refetch(failed: str) -> "str | None": @@ -358,7 +359,7 @@ async def test_bearer_auth_retries_a_401_once_with_a_fresh_token(): return "fresh-token" auth = ClientCredentialsBearerAuth("stale-token", refetch, ClientCredentialsConfig()) - async with httpx.AsyncClient(transport=transport, auth=auth) as client: + async with httpx2.AsyncClient(transport=transport, auth=auth) as client: response = await client.get("https://upstream.example.com/mcp") assert response.status_code == 200 assert refetched == ["stale-token"] @@ -370,7 +371,7 @@ async def test_bearer_auth_remembers_the_rotated_token_for_later_requests(): # The auth object lives for the whole MCP session (it is the httpx client's auth), so after a # 401 recovery it must send the fresh token first on subsequent requests; re-sending the # rejected one would burn a 401 round trip and the single retry on every call. - transport, seen = _upstream([httpx.Response(401), httpx.Response(200), httpx.Response(200)]) + transport, seen = _upstream([httpx2.Response(401), httpx2.Response(200), httpx2.Response(200)]) refetched: "list[str]" = [] async def refetch(failed: str) -> "str | None": @@ -378,7 +379,7 @@ async def test_bearer_auth_remembers_the_rotated_token_for_later_requests(): return "fresh-token" auth = ClientCredentialsBearerAuth("stale-token", refetch, ClientCredentialsConfig()) - async with httpx.AsyncClient(transport=transport, auth=auth) as client: + async with httpx2.AsyncClient(transport=transport, auth=auth) as client: first = await client.get("https://upstream.example.com/mcp") second = await client.get("https://upstream.example.com/mcp") assert first.status_code == 200 and second.status_code == 200 @@ -388,13 +389,13 @@ async def test_bearer_auth_remembers_the_rotated_token_for_later_requests(): @pytest.mark.asyncio async def test_bearer_auth_surfaces_the_401_when_the_refetch_fails(): - transport, seen = _upstream([httpx.Response(401)]) + transport, seen = _upstream([httpx2.Response(401)]) async def refetch(failed: str) -> "str | None": return None auth = ClientCredentialsBearerAuth("stale-token", refetch, ClientCredentialsConfig()) - async with httpx.AsyncClient(transport=transport, auth=auth) as client: + async with httpx2.AsyncClient(transport=transport, auth=auth) as client: response = await client.get("https://upstream.example.com/mcp") assert response.status_code == 401 assert len(seen) == 1 @@ -402,7 +403,7 @@ async def test_bearer_auth_surfaces_the_401_when_the_refetch_fails(): @pytest.mark.asyncio async def test_bearer_auth_gives_up_after_a_second_401(): - transport, seen = _upstream([httpx.Response(401), httpx.Response(401)]) + transport, seen = _upstream([httpx2.Response(401), httpx2.Response(401)]) refetched: "list[str]" = [] async def refetch(failed: str) -> "str | None": @@ -410,7 +411,7 @@ async def test_bearer_auth_gives_up_after_a_second_401(): return "fresh-token" auth = ClientCredentialsBearerAuth("stale-token", refetch, ClientCredentialsConfig()) - async with httpx.AsyncClient(transport=transport, auth=auth) as client: + async with httpx2.AsyncClient(transport=transport, auth=auth) as client: response = await client.get("https://upstream.example.com/mcp") assert response.status_code == 401 assert len(seen) == 2 @@ -422,7 +423,7 @@ def test_bearer_auth_rejects_sync_clients(): return None auth = ClientCredentialsBearerAuth("token", refetch, ClientCredentialsConfig()) - with httpx.Client(transport=httpx.MockTransport(lambda request: httpx.Response(200)), auth=auth) as client: + with httpx2.Client(transport=httpx2.MockTransport(lambda request: httpx2.Response(200)), auth=auth) as client: with pytest.raises(RuntimeError): client.get("https://upstream.example.com/mcp") @@ -431,15 +432,15 @@ def test_bearer_auth_rejects_sync_clients(): async def test_bearer_auth_writes_the_minted_token_to_the_configured_header(): seen: "list[dict[str, str]]" = [] - def handler(request: httpx.Request) -> httpx.Response: + def handler(request: httpx2.Request) -> httpx2.Response: seen.append(dict(request.headers)) - return httpx.Response(200) + return httpx2.Response(200) async def refetch(failed: str) -> "str | None": raise AssertionError("must not refetch on success") auth = ClientCredentialsBearerAuth("m2m-token", refetch, ClientCredentialsConfig(header_name="esb-oauth")) - async with httpx.AsyncClient(transport=httpx.MockTransport(handler), auth=auth) as client: + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler), auth=auth) as client: await client.get("https://upstream.example.com/mcp") assert seen[0]["esb-oauth"] == "Bearer m2m-token" assert "authorization" not in seen[0] @@ -451,9 +452,9 @@ async def test_the_401_refetch_retry_also_targets_the_configured_header(): # would silently send the fresh token to Authorization, so the ESB rejects every recovered # request while the first attempt looked correct. seen: "list[dict[str, str]]" = [] - responses = [httpx.Response(401), httpx.Response(200)] + responses = [httpx2.Response(401), httpx2.Response(200)] - def handler(request: httpx.Request) -> httpx.Response: + def handler(request: httpx2.Request) -> httpx2.Response: seen.append(dict(request.headers)) return responses[min(len(seen) - 1, len(responses) - 1)] @@ -461,7 +462,7 @@ async def test_the_401_refetch_retry_also_targets_the_configured_header(): return "fresh-token" auth = ClientCredentialsBearerAuth("stale-token", refetch, ClientCredentialsConfig(header_name="esb-oauth")) - async with httpx.AsyncClient(transport=httpx.MockTransport(handler), auth=auth) as client: + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler), auth=auth) as client: response = await client.get("https://upstream.example.com/mcp") assert response.status_code == 200 assert [h["esb-oauth"] for h in seen] == ["Bearer stale-token", "Bearer fresh-token"] diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_httpx_auth.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_httpx_auth.py index 9eab089bac6..5a5eea60fce 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_httpx_auth.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_httpx_auth.py @@ -1,10 +1,10 @@ -"""Tests for the concrete httpx.Auth objects the resolver returns. +"""Tests for the concrete httpx2.Auth objects the resolver returns. NoOpAuth must attach nothing; StaticHeaderAuth must set exactly the configured header. These pin the header emission the api_key family and passthrough depend on. """ -import httpx +import httpx2 from litellm.proxy._experimental.mcp_server.outbound_credentials import ( NoOpAuth, @@ -12,7 +12,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials import ( ) -def _apply(auth: httpx.Auth, request: httpx.Request) -> httpx.Request: +def _apply(auth: httpx2.Auth, request: httpx2.Request) -> httpx2.Request: flow = auth.auth_flow(request) sent = next(flow) flow.close() @@ -20,19 +20,19 @@ def _apply(auth: httpx.Auth, request: httpx.Request) -> httpx.Request: def test_noop_auth_attaches_no_authorization_header(): - request = httpx.Request("GET", "https://upstream.example.com/mcp") + request = httpx2.Request("GET", "https://upstream.example.com/mcp") _apply(NoOpAuth(), request) assert "authorization" not in request.headers def test_static_header_auth_defaults_to_authorization(): - request = httpx.Request("GET", "https://upstream.example.com/mcp") + request = httpx2.Request("GET", "https://upstream.example.com/mcp") _apply(StaticHeaderAuth("Bearer abc"), request) assert request.headers["Authorization"] == "Bearer abc" def test_static_header_auth_honors_custom_header_name(): - request = httpx.Request("GET", "https://upstream.example.com/mcp") + request = httpx2.Request("GET", "https://upstream.example.com/mcp") _apply(StaticHeaderAuth("raw-key", header_name="X-API-Key"), request) assert request.headers["X-API-Key"] == "raw-key" assert "authorization" not in request.headers diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py index 5fab4ceec72..0e47bbb9bb1 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py @@ -12,7 +12,7 @@ import logging import time from datetime import datetime, timedelta, timezone -import httpx +import httpx2 import jwt as pyjwt import pytest from pydantic import SecretStr @@ -109,8 +109,8 @@ def _spec(config): return ServerSpec(server_id="s", resource="https://upstream.example.com", config=config) -def _emitted(auth: httpx.Auth) -> httpx.Headers: - request = httpx.Request("GET", "https://upstream.example.com/mcp") +def _emitted(auth: httpx2.Auth) -> httpx2.Headers: + request = httpx2.Request("GET", "https://upstream.example.com/mcp") flow = auth.auth_flow(request) next(flow) flow.close() @@ -412,15 +412,15 @@ _M2M = ClientCredentialsConfig( ) -async def _emitted_async(auth: httpx.Auth, respond=None) -> tuple[httpx.Headers, list[httpx.Request]]: +async def _emitted_async(auth: httpx2.Auth, respond=None) -> tuple[httpx2.Headers, list[httpx2.Request]]: """Drive the async auth flow one request at a time, replying via ``respond`` when given.""" - seen: list[httpx.Request] = [] + seen: list[httpx2.Request] = [] - def handler(request: httpx.Request) -> httpx.Response: + def handler(request: httpx2.Request) -> httpx2.Response: seen.append(request) - return respond(request) if respond else httpx.Response(200) + return respond(request) if respond else httpx2.Response(200) - async with httpx.AsyncClient(transport=httpx.MockTransport(handler), auth=auth) as client: + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler), auth=auth) as client: await client.get("https://upstream.example.com/mcp") return seen[-1].headers, seen @@ -458,9 +458,9 @@ async def test_client_credentials_auth_retries_a_401_through_the_source(): ) assert isinstance(result, Ok) - def respond(request: httpx.Request) -> httpx.Response: + def respond(request: httpx2.Request) -> httpx2.Response: is_stale = request.headers["Authorization"] == "Bearer stale-at" - return httpx.Response(401) if is_stale else httpx.Response(200) + return httpx2.Response(401) if is_stale else httpx2.Response(200) headers, seen = await _emitted_async(result.ok, respond) assert headers["Authorization"] == "Bearer fresh-m2m" diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_elicitation_handler.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_elicitation_handler.py index b93f0d56f8e..a59b02ec01d 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_elicitation_handler.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_elicitation_handler.py @@ -30,7 +30,7 @@ def _form_params(message: str = "fill the form") -> ElicitRequestFormParams: return ElicitRequestFormParams( mode="form", message=message, - requestedSchema={"type": "object", "properties": {}}, + requested_schema={"type": "object", "properties": {}}, ) @@ -39,7 +39,7 @@ def _url_params(message: str = "please authorize") -> ElicitRequestURLParams: mode="url", message=message, url="https://example.com/oauth", - elicitationId="elc-1", + elicitation_id="elc-1", ) @@ -118,7 +118,7 @@ class TestRelayElicitationToDownstream: session.elicit_form.assert_awaited_once() _, kwargs = session.elicit_form.call_args assert kwargs["message"] == "collect name" - assert kwargs["requestedSchema"] == params.requestedSchema + assert kwargs["requested_schema"] == params.requested_schema async def test_should_relay_url_mode(self): accepted = ElicitResult(action="accept") @@ -142,7 +142,7 @@ class TestRelayElicitationToDownstream: # A bare params object that is neither Form nor URL params triggers # the generic fallback path. - params = SimpleNamespace(mode="form", message="hi", requestedSchema={}) + params = SimpleNamespace(mode="form", message="hi", requested_schema={}) result = await _relay_elicitation_to_downstream( params=params, downstream_session=session, diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_env_vars.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_env_vars.py index 36b545ad031..ca9f774e8f6 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_env_vars.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_env_vars.py @@ -1698,7 +1698,7 @@ def test_decrypt_global_env_var_drops_undecryptable_value( @pytest.mark.asyncio async def test_missing_user_env_vars_error_renders_in_mcp_call_tool(): """The MCP ``call_tool`` handler must turn ``MCPMissingUserEnvVarsError`` - into a friendly ``CallToolResult`` with ``isError=True`` so Claude Code + into a friendly ``CallToolResult`` with ``is_error=True`` so Claude Code surfaces the setup URL instead of an opaque internal error.""" from mcp.types import TextContent @@ -1714,9 +1714,9 @@ async def test_missing_user_env_vars_error_renders_in_mcp_call_tool(): result = CallToolResult( content=[TextContent(text=str(err), type="text")], - isError=True, + is_error=True, ) - assert result.isError is True + assert result.is_error is True text = result.content[0].text # type: ignore[union-attr] assert "CorporateDB" in text assert "CORP_USERNAME" in text diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_metadata_preservation.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_metadata_preservation.py index 5a24ca00c25..6c6f996977a 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_metadata_preservation.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_metadata_preservation.py @@ -38,16 +38,13 @@ class TestMCPMetadataPreservation: tool_with_metadata = MCPTool( name="hello_widget", description="Display a greeting widget", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, + meta={ + "openai/outputTemplate": "ui://widget/hello.html", + "openai/widgetDescription": "A greeting widget", + "openai/toolInvocation/invoking": "Preparing greeting...", + }, ) - # Add metadata using setattr since MCPTool might not have it in the constructor - tool_with_metadata.metadata = { - "openai/outputTemplate": "ui://widget/hello.html", - "openai/widgetDescription": "A greeting widget", - } - tool_with_metadata._meta = { - "openai/toolInvocation/invoking": "Preparing greeting...", - } # Create prefixed tools prefixed_tools = manager._create_prefixed_tools( @@ -61,22 +58,16 @@ class TestMCPMetadataPreservation: # Check that name is prefixed assert prefixed_tool.name == "test-hello_widget" - # Check that metadata is preserved - assert hasattr(prefixed_tool, "metadata") - assert prefixed_tool.metadata == { + # Check that _meta (the SDK `meta` field) is preserved + assert prefixed_tool.meta == { "openai/outputTemplate": "ui://widget/hello.html", "openai/widgetDescription": "A greeting widget", - } - - # Check that _meta is preserved - assert hasattr(prefixed_tool, "_meta") - assert prefixed_tool._meta == { "openai/toolInvocation/invoking": "Preparing greeting...", } # Check that other fields are preserved assert prefixed_tool.description == "Display a greeting widget" - assert prefixed_tool.inputSchema == {"type": "object", "properties": {}} + assert prefixed_tool.input_schema== {"type": "object", "properties": {}} if __name__ == "__main__": diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_tools.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_tools.py index 3f5d4ad83ea..b5260aaa4e9 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_tools.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_tools.py @@ -332,7 +332,7 @@ async def test_aggregate_list_tools_absorbs_one_unauthenticated_server(): "s1", "delegate_docs", auth_type=MCPAuth.oauth2, delegate_auth_to_upstream=True ) working = _http_server("s2", "working_docs", auth_type=MCPAuth.none) - good_tool = MCPTool(name="working_docs-read", description="d", inputSchema={"type": "object"}) + good_tool = MCPTool(name="working_docs-read", description="d", input_schema={"type": "object"}) async def fake_get_tools(server, **kwargs): if server.server_id == delegate.server_id: diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_proxy_mode.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_proxy_mode.py index 67b7c5a3414..f240510cbad 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_proxy_mode.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_proxy_mode.py @@ -3,7 +3,7 @@ from datetime import datetime import pytest from fastapi import HTTPException -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from pydantic import AnyUrl import litellm @@ -32,7 +32,7 @@ async def test_proxy_call_rejects_non_proxy_tool_names() -> None: ) assert result is not None - assert result.isError is True + assert result.is_error is True assert "unavailable on /mcp/proxy" in result.content[0].text @@ -44,15 +44,15 @@ async def test_proxy_rejects_non_tool_protocol_operations() -> None: assert options.capabilities.resources is None assert options.capabilities.tools is not None - with pytest.raises(McpError): + with pytest.raises(MCPError): await server.list_prompts() - with pytest.raises(McpError): + with pytest.raises(MCPError): await server.get_prompt("prompt", {}) - with pytest.raises(McpError): + with pytest.raises(MCPError): await server.list_resources() - with pytest.raises(McpError): + with pytest.raises(MCPError): await server.list_resource_templates() - with pytest.raises(McpError): + with pytest.raises(MCPError): await server.read_resource(AnyUrl("https://example.com/resource")) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sampling_completion_flow.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sampling_completion_flow.py index 78aee7b534f..73af1e501a8 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sampling_completion_flow.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sampling_completion_flow.py @@ -55,7 +55,7 @@ class TestBuildCompletionKwargs: stopSequences=["STOP"], tools=[ SimpleNamespace( - name="search", description="d", inputSchema={"type": "object"} + name="search", description="d", input_schema={"type": "object"} ) ], toolChoice=SimpleNamespace(mode="required"), @@ -179,7 +179,7 @@ class TestHandleSamplingCreateMessagePipeline: assert isinstance(result, CreateMessageResult) assert result.content.text == "the answer is 42" - assert result.stopReason == "endTurn" + assert result.stop_reason== "endTurn" async def test_should_reraise_known_proxy_exceptions(self): from litellm.exceptions import RateLimitError diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sampling_model_access.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sampling_model_access.py index 7c5320ed4f4..8975f42387b 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sampling_model_access.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sampling_model_access.py @@ -212,14 +212,14 @@ class TestSamplingAuthAndBudgetGating: ) params = MagicMock() - params.modelPreferences = None + params.model_preferences = None params.messages = [] params.systemPrompt = None - params.maxTokens = 100 + params.max_tokens = 100 params.temperature = None - params.stopSequences = None + params.stop_sequences = None params.tools = None - params.toolChoice = None + params.tool_choice = None params.metadata = None result = await handle_sampling_create_message( @@ -242,14 +242,14 @@ class TestSamplingAuthAndBudgetGating: auth = _make_user_api_key_auth(models=["gpt-4o"]) params = MagicMock() - params.modelPreferences = None + params.model_preferences = None params.messages = [] params.systemPrompt = None - params.maxTokens = 100 + params.max_tokens = 100 params.temperature = None - params.stopSequences = None + params.stop_sequences = None params.tools = None - params.toolChoice = None + params.tool_choice = None params.metadata = None with ( @@ -304,14 +304,14 @@ class TestSamplingAuthAndBudgetGating: auth = _make_user_api_key_auth(models=["gpt-4o"]) params = MagicMock() - params.modelPreferences = None + params.model_preferences = None params.messages = [] params.systemPrompt = None - params.maxTokens = 100 + params.max_tokens = 100 params.temperature = None - params.stopSequences = None + params.stop_sequences = None params.tools = None - params.toolChoice = None + params.tool_choice = None params.metadata = None budget_error = ErrorData(code=-1, message="ExceededBudget: over limit") diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sampling_response_conversion.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sampling_response_conversion.py index bb17a8f7104..63930770b5d 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sampling_response_conversion.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sampling_response_conversion.py @@ -54,13 +54,13 @@ class TestConvertOpenAIResponseToMcpResult: assert isinstance(result.content, TextContent) assert result.content.text == "hello world" assert result.role == "assistant" - assert result.stopReason == "endTurn" + assert result.stop_reason== "endTurn" def test_should_map_length_finish_reason_to_max_tokens(self): result = _convert_openai_response_to_mcp_result( _response(content="truncated", finish_reason="length"), "gpt-4o" ) - assert result.stopReason == "maxTokens" + assert result.stop_reason== "maxTokens" def test_should_prefer_actual_model_from_response(self): result = _convert_openai_response_to_mcp_result( @@ -79,7 +79,7 @@ class TestConvertOpenAIResponseToMcpResult: "gpt-4o", ) assert isinstance(result, CreateMessageResultWithTools) - assert result.stopReason == "toolUse" + assert result.stop_reason== "toolUse" tool_uses = [c for c in result.content if isinstance(c, ToolUseContent)] assert len(tool_uses) == 1 assert tool_uses[0].name == "get_weather" @@ -113,7 +113,7 @@ class TestConvertMcpToolsToOpenAI: def test_should_convert_tool_with_schema(self): schema = {"type": "object", "properties": {"q": {"type": "string"}}} tool = SimpleNamespace( - name="search", description="search the web", inputSchema=schema + name="search", description="search the web", input_schema=schema ) result = _convert_mcp_tools_to_openai([tool]) assert result == [ @@ -128,7 +128,7 @@ class TestConvertMcpToolsToOpenAI: ] def test_should_default_description_and_parameters(self): - tool = SimpleNamespace(name="noop", description=None, inputSchema=None) + tool = SimpleNamespace(name="noop", description=None, input_schema=None) result = _convert_mcp_tools_to_openai([tool]) fn = result[0]["function"] assert fn["description"] == "" diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sampling_tool_conversion.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sampling_tool_conversion.py index b4b219e958c..90ec1ab9061 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sampling_tool_conversion.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sampling_tool_conversion.py @@ -35,7 +35,7 @@ def _tool_result( if content is None: content = [] return SimpleNamespace( - type="tool_result", toolUseId=tool_use_id, content=content, isError=is_error + type="tool_result", toolUseId=tool_use_id, content=content, is_error=is_error ) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py index 8b0e4d7e47c..62a67ba45e8 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py @@ -27,14 +27,14 @@ from litellm.types.mcp import MCPAuth from litellm.types.mcp_server.mcp_server_manager import MCPOAuthMetadata, MCPServer -def test_sdk1_proxy_keeps_mcp_available(): +def test_mcp_available_on_sdk2(): from importlib.metadata import version from packaging.version import Version from litellm.proxy._experimental.mcp_server.server import MCP_AVAILABLE - assert Version("1.28.1") <= Version(version("mcp")) < Version("2") + assert Version("2.2.0") <= Version(version("mcp")) < Version("3") assert MCP_AVAILABLE is True @@ -273,7 +273,7 @@ async def test_mcp_server_tool_call_relays_upstream_auth_error_as_iserror(): with patch("litellm.proxy._experimental.mcp_server.server.verbose_logger", mock_logger): result = await mcp_server_tool_call("test_tool", {"param": "value"}) - assert result.isError is True + assert result.is_error is True # The dedicated MCPUpstreamAuthError branch (not the generic Exception fallthrough) produces this # specific message and logs at info, never a traceback via verbose_logger.exception. assert "upstream authentication required" in result.content[0].text @@ -1324,7 +1324,7 @@ async def test_get_tools_from_mcp_servers_continues_when_one_server_fails(): tool1 = MagicMock() tool1.name = "working_tool_1" tool1.description = "Working tool 1" - tool1.inputSchema = {} + tool1.input_schema= {} return [tool1] else: # Failing server raises an exception @@ -1702,13 +1702,13 @@ async def test_scoped_list_agent_veto_attributed_for_differently_cased_server_na @pytest.mark.asyncio async def test_handle_list_tools_converts_permission_httpexception_to_mcp_error(): """The MCP protocol handler surfaces a permission HTTPException as a clean JSON-RPC error - (McpError, INVALID_REQUEST) carrying the denial message, instead of a raw 500.""" + (MCPError, INVALID_REQUEST) carrying the denial message, instead of a raw 500.""" try: from litellm.proxy._experimental.mcp_server.server import handle_list_tools except ImportError: pytest.skip("MCP server not available") - from mcp.shared.exceptions import McpError + from mcp.shared.exceptions import MCPError from mcp.types import INVALID_REQUEST denial_message = "MCP server 'github' is not available to this key: the key is bound to agent 'agent-123'" @@ -1724,7 +1724,7 @@ async def test_handle_list_tools_converts_permission_httpexception_to_mcp_error( new=AsyncMock(side_effect=denial), ), ): - with pytest.raises(McpError) as exc_info: + with pytest.raises(MCPError) as exc_info: await handle_list_tools() assert exc_info.value.error.code == INVALID_REQUEST @@ -1753,7 +1753,7 @@ async def test_mcp_server_tool_call_renders_denial_message_not_detail_dict(): ): result = await mcp_server_tool_call("github-search_issues", {}) - assert result.isError is True + assert result.is_error is True assert result.content[0].text == f"Error: {denial_message}" @@ -3624,7 +3624,7 @@ async def test_list_tools_single_server_unprefixed_names(): tool = MagicMock() tool.name = f"{server.alias}-toolA" if add_prefix else "toolA" tool.description = "desc" - tool.inputSchema = {} + tool.input_schema= {} return [tool] mock_manager._get_tools_from_server = mock_get_tools_from_server @@ -3703,7 +3703,7 @@ async def test_list_tools_multiple_servers_prefixed_names(): # When multiple servers, add_prefix should be True -> prefixed names tool.name = f"{server.alias}-toolA" if add_prefix else "toolA" tool.description = "desc" - tool.inputSchema = {} + tool.input_schema= {} return [tool] mock_manager._get_tools_from_server = mock_get_tools_from_server @@ -4116,22 +4116,22 @@ async def test_list_tools_filters_by_key_team_permissions(): tool1 = MagicMock() tool1.name = "tool1" tool1.description = "Tool 1" - tool1.inputSchema = {} + tool1.input_schema= {} tool2 = MagicMock() tool2.name = "tool2" tool2.description = "Tool 2" - tool2.inputSchema = {} + tool2.input_schema= {} tool3 = MagicMock() tool3.name = "tool3" tool3.description = "Tool 3 - not allowed" - tool3.inputSchema = {} + tool3.input_schema= {} tool4 = MagicMock() tool4.name = "tool4" tool4.description = "Tool 4 - not allowed" - tool4.inputSchema = {} + tool4.input_schema= {} return [tool1, tool2, tool3, tool4] @@ -4227,22 +4227,22 @@ async def test_list_tools_with_team_tool_permissions_inheritance(): tool1 = MagicMock() tool1.name = "tool1" tool1.description = "Tool 1" - tool1.inputSchema = {} + tool1.input_schema= {} tool2 = MagicMock() tool2.name = "tool2" tool2.description = "Tool 2" - tool2.inputSchema = {} + tool2.input_schema= {} tool3 = MagicMock() tool3.name = "tool3" tool3.description = "Tool 3" - tool3.inputSchema = {} + tool3.input_schema= {} tool4 = MagicMock() tool4.name = "tool4" tool4.description = "Tool 4" - tool4.inputSchema = {} + tool4.input_schema= {} return [tool1, tool2, tool3, tool4] @@ -4324,17 +4324,17 @@ async def test_list_tools_with_no_tool_permissions_shows_all(): tool1 = MagicMock() tool1.name = "tool1" tool1.description = "Tool 1" - tool1.inputSchema = {} + tool1.input_schema= {} tool2 = MagicMock() tool2.name = "tool2" tool2.description = "Tool 2" - tool2.inputSchema = {} + tool2.input_schema= {} tool3 = MagicMock() tool3.name = "tool3" tool3.description = "Tool 3" - tool3.inputSchema = {} + tool3.input_schema= {} return [tool1, tool2, tool3] @@ -4425,22 +4425,22 @@ async def test_list_tools_strips_prefix_when_matching_permissions(): tool1 = MagicMock() tool1.name = "GITMCP-fetch_litellm_documentation" # Prefixed tool1.description = "Fetch docs" - tool1.inputSchema = {} + tool1.input_schema= {} tool2 = MagicMock() tool2.name = "GITMCP-search_litellm_documentation" # Prefixed, not in allowed list tool2.description = "Search docs" - tool2.inputSchema = {} + tool2.input_schema= {} tool3 = MagicMock() tool3.name = "GITMCP-search_litellm_code" # Prefixed tool3.description = "Search code" - tool3.inputSchema = {} + tool3.input_schema= {} tool4 = MagicMock() tool4.name = "GITMCP-fetch_generic_url_content" # Prefixed, not in allowed list tool4.description = "Fetch URL" - tool4.inputSchema = {} + tool4.input_schema= {} return [tool1, tool2, tool3, tool4] @@ -4490,7 +4490,7 @@ def test_filter_tools_by_allowed_tools(): name="my_api_mcp-getpetbyid", title=None, description="Find pet by ID", - inputSchema={ + input_schema={ "type": "object", "properties": {"petId": {"type": "integer", "description": ""}}, "required": ["petId"], @@ -4502,7 +4502,7 @@ def test_filter_tools_by_allowed_tools(): name="my_api_mcp-findpetsbystatus", title=None, description="Finds Pets by status", - inputSchema={ + input_schema={ "type": "object", "properties": {"status": {"type": "string", "description": ""}}, "required": ["status"], @@ -4514,7 +4514,7 @@ def test_filter_tools_by_allowed_tools(): name="my_api_mcp-addpet", title=None, description="Add a new pet to the store", - inputSchema={ + input_schema={ "type": "object", "properties": { "body": { @@ -4560,7 +4560,7 @@ def test_apply_tool_overrides(): name="my_api_mcp-getpetbyid", title=None, description="Original description", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, outputSchema=None, annotations=None, ), @@ -4568,7 +4568,7 @@ def test_apply_tool_overrides(): name="my_api_mcp-findpetsbystatus", title=None, description="Finds Pets by status", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, outputSchema=None, annotations=None, ), @@ -4602,7 +4602,7 @@ def test_apply_tool_overrides_no_overrides(): name="my_api_mcp-getpetbyid", title=None, description="Original description", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, outputSchema=None, annotations=None, ), @@ -4943,7 +4943,7 @@ async def test_get_tools_from_mcp_servers_logs_list_tools_to_spendlogs_when_enab tool_1 = MCPTool( name="server_a-tool_1", description="test tool", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ) dummy_logging_obj = MagicMock() @@ -5249,7 +5249,7 @@ def test_filter_tools_enforced_empty_allowlist_blocks_all(): name="read_wiki_structure", title=None, description="", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, outputSchema=None, annotations=None, ), @@ -5279,7 +5279,7 @@ def test_filter_tools_legacy_empty_allowlist_allows_all(): name="read_wiki_structure", title=None, description="", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, outputSchema=None, annotations=None, ), @@ -6643,7 +6643,7 @@ async def test_execute_mcp_tool_rest_server_id_authoritative_for_unprefixed_tool captured.update(kwargs) return mcp_module.CallToolResult( content=[TextContent(type="text", text="ok")], - isError=False, + is_error=False, ) with ( @@ -6722,7 +6722,7 @@ async def test_execute_mcp_tool_strips_a_prefix_that_contains_the_separator(): captured.update(kwargs) return mcp_module.CallToolResult( content=[TextContent(type="text", text="ok")], - isError=False, + is_error=False, ) with ( @@ -6789,7 +6789,7 @@ async def test_execute_mcp_tool_rest_server_id_injects_requested_server_credenti fake_client.call_tool = AsyncMock( return_value=mcp_module.CallToolResult( content=[TextContent(type="text", text="ok")], - isError=False, + is_error=False, ) ) @@ -6993,7 +6993,7 @@ async def test_execute_mcp_tool_rest_hyphenated_upstream_tool_name_routes_to_req captured.update(kwargs) return mcp_module.CallToolResult( content=[TextContent(type="text", text="ok")], - isError=False, + is_error=False, ) with ( @@ -7156,7 +7156,7 @@ async def test_execute_mcp_tool_rest_unresolved_prefixed_name_routes_to_requeste captured.update(kwargs) return mcp_module.CallToolResult( content=[TextContent(type="text", text="ok")], - isError=False, + is_error=False, ) with ( @@ -7733,7 +7733,7 @@ async def test_stateful_mcp_tool_call_uses_current_requests_otel_destinations() request_token = request_ctx.set(current_request_context) try: result = await mcp_server_tool_call("otelcontext-observe", {}) - assert result.isError is False + assert result.is_error is False assert request_destinations() == (initialized_destination,) finally: request_ctx.reset(request_token) @@ -7832,7 +7832,7 @@ async def test_get_active_submitted_mcp_server_ids_for_user_empty_user_id_skips_ def _call_tool_result(is_error: bool, text: str) -> CallToolResult: - return CallToolResult(content=[TextContent(type="text", text=text)], isError=is_error) + return CallToolResult(content=[TextContent(type="text", text=text)], is_error=is_error) def _mock_mcp_logging_obj() -> MagicMock: @@ -7860,7 +7860,7 @@ def test_extract_mcp_tool_result_error_message(): assert extract_mcp_tool_result_error_message(_call_tool_result(True, "boom")) == "boom" assert extract_mcp_tool_result_error_message(_call_tool_result(False, "ok")) is None assert ( - extract_mcp_tool_result_error_message(CallToolResult(content=[], isError=True)) + extract_mcp_tool_result_error_message(CallToolResult(content=[], is_error=True)) == "MCP tool call returned isError=true" ) assert ( @@ -7873,7 +7873,7 @@ def test_extract_mcp_tool_result_error_message(): @pytest.mark.asyncio async def test_fire_mcp_tool_call_logging_iserror_logs_failure(): - """Regression test: a CallToolResult with isError=True must go + """Regression test: a CallToolResult with is_error=True must go down the failure logging path (async_failure_handler + post_call_failure_hook), never async_success_handler.""" from litellm.proxy._experimental.mcp_server.server import ( @@ -7913,7 +7913,7 @@ async def test_fire_mcp_tool_call_logging_iserror_logs_failure(): @pytest.mark.asyncio async def test_fire_mcp_tool_call_logging_success_path_unchanged(): - """isError=False must keep today's behavior: success handler fires, no + """is_error=False must keep today's behavior: success handler fires, no failure logging, no post_call_failure_hook.""" from litellm.proxy._experimental.mcp_server.server import ( _fire_mcp_tool_call_logging, @@ -8032,7 +8032,7 @@ def _real_mcp_logging_obj(call_id: str): @pytest.mark.asyncio async def test_fire_mcp_tool_call_logging_iserror_builds_failure_payload(monkeypatch): - """The standard logging payload for an isError=True result must carry + """The standard logging payload for an is_error=True result must carry status='failure' with the tool's error text, so OTel (whose _parse_error keys off status) marks the MCP span ERROR.""" import litellm @@ -8063,7 +8063,7 @@ async def test_fire_mcp_tool_call_logging_iserror_builds_failure_payload(monkeyp @pytest.mark.asyncio async def test_fire_mcp_tool_call_logging_success_builds_success_payload(monkeypatch): - """isError=False still produces a status='success' payload.""" + """is_error=False still produces a status='success' payload.""" import litellm from litellm.proxy._experimental.mcp_server.server import ( _fire_mcp_tool_call_logging, @@ -8089,9 +8089,9 @@ async def test_fire_mcp_tool_call_logging_success_builds_success_payload(monkeyp @pytest.mark.asyncio async def test_fire_mcp_tool_call_logging_iserror_emits_otel_error_span(monkeypatch): - """End-to-end regression for the OTel symptom: an isError=True tool + """End-to-end regression for the OTel symptom: an is_error=True tool result must reach OTel as an MCP span with StatusCode.ERROR and the tool's - error message, while isError=False stays non-error.""" + error message, while is_error=False stays non-error.""" pytest.importorskip("opentelemetry") from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( InMemorySpanExporter, @@ -8336,7 +8336,7 @@ async def test_aggregate_listing_reports_per_server_outcomes(): tool1 = MagicMock() tool1.name = "working_tool_1" tool1.description = "Working tool 1" - tool1.inputSchema = {} + tool1.input_schema= {} return [tool1] raise MCPServerListError(ServerListFault(tag="upstream_error", status_code=500), server.name) @@ -8402,7 +8402,7 @@ async def test_handle_list_tools_attaches_outcome_meta(): ServerListOk, ) - tool = Tool(name="t1", inputSchema={"type": "object"}) + tool = Tool(name="t1", input_schema={"type": "object"}) listing = AggregateToolListing( tools=[tool], outcomes={"healthy": ServerListOk(tool_count=1), "broken": ServerListFault(tag="unreachable")}, @@ -8966,7 +8966,7 @@ class TestListFiltersHonorThePrefixBoundary: from mcp.types import Tool as MCPTool return [ - MCPTool(name=f"{self.SERVER_ID}-{bare}", description=bare, inputSchema={"type": "object"}) + MCPTool(name=f"{self.SERVER_ID}-{bare}", description=bare, input_schema={"type": "object"}) for bare in bare_names ] @@ -9070,13 +9070,13 @@ class TestListFiltersHonorThePrefixBoundary: manager = MCPServerManager() manager._create_prefixed_tools( - [MCPTool(name="read_wiki_contents", description="", inputSchema={"type": "object"})], + [MCPTool(name="read_wiki_contents", description="", input_schema={"type": "object"})], _server(), ) registered = sorted(manager.tool_name_to_mcp_server_name_mapping) assert len(registered) > 1 - published = MCPTool(name="eiG-read_wiki_contents", description="", inputSchema={"type": "object"}) + published = MCPTool(name="eiG-read_wiki_contents", description="", input_schema={"type": "object"}) for spelling in registered: for entry, expected in ((spelling, True), (spelling.upper(), False)): server = _server(disallowed_tools=[entry]) @@ -9125,7 +9125,7 @@ class TestListFiltersHonorThePrefixBoundary: url="http://127.0.0.1:5115/mcp", transport=MCPTransport.http, ) - published = MCPTool(name=f"{self.SERVER_ID}-read_wiki_contents", description="", inputSchema={"type": "object"}) + published = MCPTool(name=f"{self.SERVER_ID}-read_wiki_contents", description="", input_schema={"type": "object"}) auth = UserAPIKeyAuth(api_key="sk-test") with ( @@ -9182,7 +9182,7 @@ async def test_list_tools_injects_byok_credential_for_non_oauth2_auth_types(auth tool = MagicMock() tool.name = f"{server.alias}-toolA" if add_prefix else "toolA" tool.description = "desc" - tool.inputSchema = {} + tool.input_schema= {} return [tool] mock_manager = MagicMock() diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index d449ad06642..50e3a1d941f 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -22,7 +22,10 @@ from litellm.proxy._experimental.mcp_server.faults.list_outcomes import ServerLi # Add the parent directory to the path so we can import litellm +import contextlib + import httpx +import httpx2 from mcp import ReadResourceResult, Resource from mcp.types import ( CallToolResult, @@ -1664,7 +1667,7 @@ class TestMCPServerManager: ) mock_client = AsyncMock() - mock_client.call_tool = AsyncMock(return_value=CallToolResult(content=[], isError=False)) + mock_client.call_tool = AsyncMock(return_value=CallToolResult(content=[], is_error=False)) captured_extra_headers = None async def capture_create_mcp_client( @@ -1868,7 +1871,7 @@ class TestMCPServerManager: never wrapped as MCPUpstreamAuthError or replaced by error_tool_result.""" server = self._passthrough_call_server(MCPAuth.true_passthrough, server_id=f"pt-ok-{is_error}") manager = MCPServerManager() - expected = CallToolResult(content=[], isError=is_error) + expected = CallToolResult(content=[], is_error=is_error) mock_client = AsyncMock() mock_client.call_tool = AsyncMock(return_value=expected) manager._create_mcp_client = AsyncMock(return_value=mock_client) @@ -1899,7 +1902,7 @@ class TestMCPServerManager: with patch.object(_mgr_mod, "verbose_logger") as mock_log: result = await self._run_call_regular(manager, server) - assert result.isError is True + assert result.is_error is True # A genuine non-auth failure keeps operator visibility at warning level, since call_tool's # raise_on_error demoted the client-layer error log to debug. assert mock_log.warning.called @@ -1918,7 +1921,7 @@ class TestMCPServerManager: ) manager = MCPServerManager() mock_client = AsyncMock() - mock_client.call_tool = AsyncMock(return_value=CallToolResult(content=[], isError=False)) + mock_client.call_tool = AsyncMock(return_value=CallToolResult(content=[], is_error=False)) manager._create_mcp_client = AsyncMock(return_value=mock_client) result = await manager._call_regular_mcp_tool( @@ -1933,7 +1936,7 @@ class TestMCPServerManager: proxy_logging_obj=None, ) - assert result.isError is False + assert result.is_error is False assert mock_client.call_tool.call_args.kwargs.get("raise_on_error") is not True def _token_exchange_server(self, server_id: str) -> "MCPServer": @@ -3089,7 +3092,7 @@ class TestMCPServerManager: ) mock_client = AsyncMock() - mock_client.call_tool = AsyncMock(return_value=CallToolResult(content=[], isError=False)) + mock_client.call_tool = AsyncMock(return_value=CallToolResult(content=[], is_error=False)) captured_extra_headers = None async def capture_create_mcp_client( @@ -3148,7 +3151,7 @@ class TestMCPServerManager: assert _should_strip_caller_authorization(mcp_server=server, raw_headers=None, user_api_key_auth=None) is True mock_client = AsyncMock() - mock_client.call_tool = AsyncMock(return_value=CallToolResult(content=[], isError=False)) + mock_client.call_tool = AsyncMock(return_value=CallToolResult(content=[], is_error=False)) captured_extra_headers = "unset" async def capture_create_mcp_client( @@ -3216,7 +3219,7 @@ class TestMCPServerManager: ) mock_client = AsyncMock() - mock_client.call_tool = AsyncMock(return_value=CallToolResult(content=[], isError=False)) + mock_client.call_tool = AsyncMock(return_value=CallToolResult(content=[], is_error=False)) captured_extra_headers = None async def capture_create_mcp_client( @@ -3273,7 +3276,7 @@ class TestMCPServerManager: ) mock_client = AsyncMock() - mock_client.call_tool = AsyncMock(return_value=CallToolResult(content=[], isError=False)) + mock_client.call_tool = AsyncMock(return_value=CallToolResult(content=[], is_error=False)) captured_extra_headers = None async def capture_create_mcp_client( @@ -3308,7 +3311,7 @@ class TestMCPServerManager: async def _capture_call_extra_headers(self, server, oauth2_headers, raw_headers, user_api_key_auth): manager = MCPServerManager() mock_client = AsyncMock() - mock_client.call_tool = AsyncMock(return_value=CallToolResult(content=[], isError=False)) + mock_client.call_tool = AsyncMock(return_value=CallToolResult(content=[], is_error=False)) captured = {"extra_headers": "unset"} async def capture_create_mcp_client( @@ -5488,7 +5491,7 @@ class TestMCPServerManager: upstream_tool = MCPTool( name="send_email", description="Send an email", - inputSchema={}, + input_schema={}, ) manager._fetch_tools_with_timeout = AsyncMock(return_value=[upstream_tool]) @@ -6020,12 +6023,12 @@ class TestMCPServerManager: t1 = MCPTool( name="create_issue", description="", - inputSchema={}, + input_schema={}, ) t2 = MCPTool( name="close_issue", description="", - inputSchema={}, + input_schema={}, ) # Do not add prefix in returned objects @@ -6059,7 +6062,7 @@ class TestMCPServerManager: base_tool = MCPTool( name="create_zap", description="", - inputSchema={}, + input_schema={}, ) _ = manager._create_prefixed_tools([base_tool], server, add_prefix=False) @@ -6093,17 +6096,17 @@ class TestMCPServerManager: tool1 = MagicMock() tool1.name = "allowed_tool_1" tool1.description = "This tool is allowed" - tool1.inputSchema = {} + tool1.input_schema= {} tool2 = MagicMock() tool2.name = "blocked_tool" tool2.description = "This tool is not allowed" - tool2.inputSchema = {} + tool2.input_schema= {} tool3 = MagicMock() tool3.name = "allowed_tool_2" tool3.description = "This tool is also allowed" - tool3.inputSchema = {} + tool3.input_schema= {} # Mock the global_mcp_server_manager._get_tools_from_server from litellm.proxy._experimental.mcp_server import rest_endpoints @@ -6143,17 +6146,17 @@ class TestMCPServerManager: tool1 = MagicMock() tool1.name = "tool_1" tool1.description = "Tool 1" - tool1.inputSchema = {} + tool1.input_schema= {} tool2 = MagicMock() tool2.name = "tool_2" tool2.description = "Tool 2" - tool2.inputSchema = {} + tool2.input_schema= {} tool3 = MagicMock() tool3.name = "tool_3" tool3.description = "Tool 3" - tool3.inputSchema = {} + tool3.input_schema= {} # Mock the global_mcp_server_manager._get_tools_from_server from litellm.proxy._experimental.mcp_server import rest_endpoints @@ -6193,12 +6196,12 @@ class TestMCPServerManager: tool1 = MagicMock() tool1.name = "tool_1" tool1.description = "Tool 1" - tool1.inputSchema = {} + tool1.input_schema= {} tool2 = MagicMock() tool2.name = "tool_2" tool2.description = "Tool 2" - tool2.inputSchema = {} + tool2.input_schema= {} # Mock the global_mcp_server_manager._get_tools_from_server from litellm.proxy._experimental.mcp_server import rest_endpoints @@ -6538,7 +6541,7 @@ class TestMCPServerManager: # Return a mock CallToolResult result = MagicMock(spec=CallToolResult) result.content = [{"type": "text", "text": "Tool executed successfully"}] - result.isError = False + result.is_error= False return result mock_client.call_tool.side_effect = mock_call_tool @@ -6569,7 +6572,7 @@ class TestMCPServerManager: # Verify the result assert result is not None - assert result.isError is False + assert result.is_error is False assert len(result.content) > 0 # Verify the MCP client call was awaited exactly once @@ -9754,7 +9757,7 @@ class TestMCPToolsListAuthSurfacing: manager.get_mcp_server_by_id = MagicMock( side_effect=lambda server_id: {"good": good, "bad": bad}.get(server_id) ) - good_tool = MCPTool(name="good-do_thing", description="do thing", inputSchema={}) + good_tool = MCPTool(name="good-do_thing", description="do thing", input_schema={}) async def fake_get_tools(server, **kwargs): if server.server_id == "bad": @@ -9869,7 +9872,7 @@ class TestOBOCallToolRetry: @pytest.mark.asyncio async def test_upstream_401_invalidates_and_retries_once(self): manager = self._manager() - success = CallToolResult(content=[], isError=False) + success = CallToolResult(content=[], is_error=False) first = _RetryFakeClient(raises=_UpstreamAuthError(401)) retry = _RetryFakeClient(result=success) manager._create_mcp_client = AsyncMock(return_value=retry) @@ -9900,7 +9903,7 @@ class TestOBOCallToolRetry: ) manager = self._manager() - success = CallToolResult(content=[], isError=False) + success = CallToolResult(content=[], is_error=False) first = _RetryFakeClient(raises=_UpstreamAuthError(401)) retry = _RetryFakeClient(result=success) manager._create_mcp_client = AsyncMock(return_value=retry) @@ -9939,7 +9942,7 @@ class TestOBOCallToolRetry: """An oauth2_id_jag tool call with a subject token must take the invalidate-and-retry branch of _call_regular_mcp_tool, not the plain single call, so an upstream 401 re-exchanges.""" manager = self._manager() - success = CallToolResult(content=[], isError=False) + success = CallToolResult(content=[], is_error=False) first = _RetryFakeClient(raises=_UpstreamAuthError(401)) retry = _RetryFakeClient(result=success) manager._create_mcp_client = AsyncMock(side_effect=[first, retry]) @@ -9989,7 +9992,7 @@ class TestOBOCallToolRetry: user_api_key_auth=None, ) - assert result.isError is True + assert result.is_error is True manager._cred_provider.invalidate_credentials.assert_not_awaited() manager._create_mcp_client.assert_not_awaited() assert first.attempts == 1 @@ -10014,7 +10017,7 @@ class TestOBOCallToolRetry: user_api_key_auth=None, ) - assert result.isError is True + assert result.is_error is True manager._create_mcp_client.assert_awaited_once() assert first.attempts == 1 and retry.attempts == 1 @@ -10054,7 +10057,7 @@ class TestOBOConcurrencyLimit: await release.wait() finally: inflight["current"] -= 1 - return CallToolResult(content=[], isError=False) + return CallToolResult(content=[], is_error=False) manager = MCPServerManager() manager._create_mcp_client = AsyncMock(return_value=_ConcurrencyRecordingClient()) @@ -10093,7 +10096,7 @@ class TestOBOConcurrencyLimit: assert peak_while_blocked == max_concurrent assert inflight["current"] == 0 - assert all(result.isError is False for result in results) + assert all(result.is_error is False for result in results) class TestOBOEndpointDiscovery: @@ -10268,7 +10271,7 @@ async def test_aggregate_list_still_absorbs_step_up_challenged_server(): ca = MCPServer(server_id="ca", name="ca", transport=MCPTransport.http) manager.get_allowed_mcp_servers = AsyncMock(return_value=["good", "ca"]) manager.get_mcp_server_by_id = MagicMock(side_effect=lambda server_id: {"good": good, "ca": ca}.get(server_id)) - good_tool = MCPTool(name="good-do_thing", description="do thing", inputSchema={}) + good_tool = MCPTool(name="good-do_thing", description="do thing", input_schema={}) async def fake_get_tools(server, **kwargs): if server.server_id == "ca": @@ -11016,7 +11019,7 @@ class TestServerToolListsHonorThePrefixBoundary: shape = self._aliased_server(short_prefix="F3X") manager = MCPServerManager() - manager._create_prefixed_tools([MCPTool(name="deletepet", description="", inputSchema={})], shape) + manager._create_prefixed_tools([MCPTool(name="deletepet", description="", input_schema={})], shape) registered = sorted(manager.tool_name_to_mcp_server_name_mapping) assert len(registered) > 1 @@ -11219,7 +11222,7 @@ class TestOpenAPIRegistryKeyMatchesRegistration: result = await self._call(server, registered_key, "list_pets") - assert result.isError is False + assert result.is_error is False assert result.content[0].text == "dispatched" @pytest.mark.asyncio @@ -11236,7 +11239,7 @@ class TestOpenAPIRegistryKeyMatchesRegistration: result = await self._call(server, registered_key, "read_wiki_contents") - assert result.isError is False + assert result.is_error is False assert result.content[0].text == "dispatched" @pytest.mark.asyncio @@ -11259,7 +11262,7 @@ class TestOpenAPIRegistryKeyMatchesRegistration: result = await self._call(server, registered_key, "petstore-list_pets") - assert result.isError is False + assert result.is_error is False assert result.content[0].text == "dispatched" @pytest.mark.asyncio @@ -11282,7 +11285,7 @@ class TestOpenAPIRegistryKeyMatchesRegistration: result = await self._call(server, registered_key, "list_pets") - assert result.isError is False + assert result.is_error is False assert result.content[0].text == "dispatched" @pytest.mark.asyncio @@ -11299,7 +11302,7 @@ class TestOpenAPIRegistryKeyMatchesRegistration: result = await self._call(server, "petstore-list_pets", "delete_pet") - assert result.isError is True + assert result.is_error is True assert "not found in registry" in result.content[0].text @@ -11341,7 +11344,7 @@ class TestToolAuthorizationIsNotConditionalOnLogging: @pytest.mark.asyncio async def test_unentitled_tool_refused_without_proxy_logging_obj(self): manager, user = self._manager_with_scoped_server() - upstream = AsyncMock(return_value=CallToolResult(content=[], isError=False)) + upstream = AsyncMock(return_value=CallToolResult(content=[], is_error=False)) with patch.object(manager, "_call_regular_mcp_tool", new=upstream): with pytest.raises(HTTPException) as exc: @@ -11361,7 +11364,7 @@ class TestToolAuthorizationIsNotConditionalOnLogging: """The gate must refuse only what the entitlement excludes; an allowed tool still reaches the upstream when there is no logging object.""" manager, user = self._manager_with_scoped_server() - upstream = AsyncMock(return_value=CallToolResult(content=[], isError=False)) + upstream = AsyncMock(return_value=CallToolResult(content=[], is_error=False)) with patch.object(manager, "_call_regular_mcp_tool", new=upstream): await manager.call_tool( @@ -11574,7 +11577,7 @@ class TestClientForwardedDiscoveryFailureIsNotFatal: server = await self._registered(manager, auth_type, None) manager._set_oauth_discovery_deferred(server.server_id, True) manager._fetch_tools_with_timeout = AsyncMock( - return_value=[MCPTool(name="list_reports", description="d", inputSchema={"type": "object"})] + return_value=[MCPTool(name="list_reports", description="d", input_schema={"type": "object"})] ) with patch.object(manager, "_discover_oauth_metadata_for_server", new=AsyncMock(return_value=None)): @@ -11796,7 +11799,7 @@ class TestOpenApiHandlerRelaysUpstreamAuth: with patch.object(global_mcp_tool_registry, "get_tool", return_value=tool): result = await manager._call_openapi_tool_handler(self._server(), "list_reports", {}) - assert result.isError is True + assert result.is_error is True assert "upstream returned HTTP 503" in result.content[0].text @@ -12420,7 +12423,7 @@ class TestLitellmAdmissionKeyIsNeverTheSubjectToken: def _manager_with_recording_client() -> MCPServerManager: manager: Final = MCPServerManager() client: Final = AsyncMock() - client.call_tool = AsyncMock(return_value=CallToolResult(content=[], isError=False)) + client.call_tool = AsyncMock(return_value=CallToolResult(content=[], is_error=False)) client.list_prompts = AsyncMock(return_value=[]) client.read_resource = AsyncMock(return_value=ReadResourceResult(contents=[])) manager._create_mcp_client = AsyncMock(return_value=client) @@ -13049,6 +13052,24 @@ class _DiscoveryClock: return self.now +from pydantic import TypeAdapter +from mcp.types import JSONRPCMessage + +_JSONRPC_ADAPTER = TypeAdapter(JSONRPCMessage) + + +@contextlib.contextmanager +def _mcp_upstream(respond): + """Drive the SDK's streamable-HTTP transport off an httpx2 MockTransport; respx only sees httpx.""" + from litellm.experimental_mcp_client.client import MCPClient + + def factory(*args, **kwargs): + return httpx2.AsyncClient(transport=httpx2.MockTransport(respond)) + + with patch.object(MCPClient, "_create_httpx_client_factory", lambda self: factory): + yield + + class _DiscoveryUpstream: def __init__(self) -> None: self.requests: tuple[tuple[str, str], ...] = () @@ -13057,17 +13078,17 @@ class _DiscoveryUpstream: self.release = asyncio.Event() self.release.set() - async def respond(self, request: httpx.Request) -> httpx.Response: - from mcp.types import JSONRPCMessage, JSONRPCRequest + async def respond(self, request: httpx2.Request) -> httpx2.Response: + from mcp.types import JSONRPCRequest if request.method == "DELETE": - return httpx.Response(200) - payload: Final = JSONRPCMessage.model_validate_json(request.content).root + return httpx2.Response(200) + payload: Final = _JSONRPC_ADAPTER.validate_json(request.content) if not isinstance(payload, JSONRPCRequest): - return httpx.Response(202) + return httpx2.Response(202) self.requests = (*self.requests, (payload.method, request.headers.get("authorization", ""))) if payload.method == "initialize": - return httpx.Response(200, json={ + return httpx2.Response(200, json={ "jsonrpc": "2.0", "id": payload.id, "result": {"protocolVersion": "2025-03-26", "serverInfo": {"name": "discovery", "version": "1"}, "capabilities": {} if self.outcome == "unsupported" else {"prompts": {}, "resources": {}}}, @@ -13075,11 +13096,11 @@ class _DiscoveryUpstream: self.entered.set() await self.release.wait() if self.outcome == "failure": - return httpx.Response(503) + return httpx2.Response(503) if self.outcome == "cancelled": raise asyncio.CancelledError() if self.outcome == "rejected": - return httpx.Response(200, json={"jsonrpc": "2.0", "id": payload.id, + return httpx2.Response(200, json={"jsonrpc": "2.0", "id": payload.id, "error": {"code": -32601, "message": "Unsupported"}}) result: Final = { "prompts/list": {"prompts": [{"name": "example", "description": "original"}]}, @@ -13087,7 +13108,7 @@ class _DiscoveryUpstream: "resources/templates/list": {"resourceTemplates": [{"name": "example", "uriTemplate": "test://{name}", "description": "original"}]}, "tools/list": {"tools": []}, }[payload.method] - return httpx.Response(200, json={"jsonrpc": "2.0", "id": payload.id, "result": result}) + return httpx2.Response(200, json={"jsonrpc": "2.0", "id": payload.id, "result": result}) @property def initializes(self) -> int: @@ -13109,8 +13130,7 @@ async def test_discovery_cache_reuses_raw_results_and_expires(kind: str) -> None operation: Final = {"prompts": manager.get_prompts_from_server, "resources": manager.get_resources_from_server, "templates": manager.get_resource_templates_from_server}[kind] server: Final = _discovery_server() - with respx.mock(base_url="https://discovery.example") as router: - router.route().mock(side_effect=upstream.respond) + with _mcp_upstream(upstream.respond): first: Final = await operation(server, None) assert len(first) == 1 assert first[0].name == "discovery-example" @@ -13138,8 +13158,7 @@ async def test_discovery_cache_empty_results_and_failures(kind: str, outcome: st upstream.outcome = outcome operation: Final = {"prompts": manager.get_prompts_from_server, "resources": manager.get_resources_from_server, "templates": manager.get_resource_templates_from_server}[kind] - with respx.mock(base_url="https://discovery.example") as router: - router.route().mock(side_effect=upstream.respond) + with _mcp_upstream(upstream.respond): assert await operation(_discovery_server(), None) == [] assert await operation(_discovery_server(), None) == [] assert upstream.initializes == (2 if outcome == "failure" else 1) @@ -13158,8 +13177,7 @@ async def test_discovery_cache_isolates_forwarded_credentials_and_shares_static_ server: Final = _discovery_server() first_user: Final = UserAPIKeyAuth(user_id="first") second_user: Final = UserAPIKeyAuth(user_id="second") - with respx.mock(base_url="https://discovery.example") as router: - router.route().mock(side_effect=upstream.respond) + with _mcp_upstream(upstream.respond): for user in (first_user, second_user): assert len(await manager.get_prompts_from_server(server, user)) == 1 assert upstream.initializes == 1 @@ -13176,8 +13194,7 @@ async def test_discovery_cache_coalesces_and_survives_waiter_cancellation() -> N manager: Final = MCPServerManager() upstream: Final = _DiscoveryUpstream() upstream.release.clear() - with respx.mock(base_url="https://discovery.example") as router: - router.route().mock(side_effect=upstream.respond) + with _mcp_upstream(upstream.respond): tasks: Final = tuple(asyncio.create_task(manager.get_prompts_from_server(_discovery_server(), None)) for _ in range(10)) await asyncio.wait_for(upstream.entered.wait(), timeout=5) tasks[0].cancel() @@ -13199,8 +13216,7 @@ async def test_discovery_cache_invalidation_during_fetch_does_not_repopulate_old manager: Final = MCPServerManager() upstream: Final = _DiscoveryUpstream() upstream.release.clear() - with respx.mock(base_url="https://discovery.example") as router: - router.route().mock(side_effect=upstream.respond) + with _mcp_upstream(upstream.respond): task: Final = asyncio.create_task(manager.get_prompts_from_server(_discovery_server(), None)) await asyncio.wait_for(upstream.entered.wait(), timeout=5) manager._invalidate_discovery_lists("discovery") @@ -13220,8 +13236,7 @@ async def test_discovery_cache_can_be_disabled(monkeypatch: pytest.MonkeyPatch) monkeypatch.setenv("LITELLM_MCP_DISCOVERY_CACHE_TTL", "0") manager: Final = MCPServerManager() upstream: Final = _DiscoveryUpstream() - with respx.mock(base_url="https://discovery.example") as router: - router.route().mock(side_effect=upstream.respond) + with _mcp_upstream(upstream.respond): assert len(await manager.get_prompts_from_server(_discovery_server(), None)) == 1 assert len(await manager.get_prompts_from_server(_discovery_server(), None)) == 1 assert upstream.initializes == 2 @@ -13352,19 +13367,18 @@ async def test_discovery_cache_tracks_resolved_credentials_across_workers() -> N user: Final = UserAPIKeyAuth(user_id="same-user", api_key="same-key") upstream: Final = _DiscoveryUpstream() - async def respond(request: httpx.Request) -> httpx.Response: + async def respond(request: httpx2.Request) -> httpx2.Response: response: Final = await upstream.respond(request) if '"prompts/list"' not in request.content.decode(): return response - from mcp.types import JSONRPCMessage, JSONRPCRequest + from mcp.types import JSONRPCRequest - payload: Final = JSONRPCMessage.model_validate_json(request.content).root + payload: Final = _JSONRPC_ADAPTER.validate_json(request.content) assert isinstance(payload, JSONRPCRequest) name: Final = {"Bearer token-a": "account-a", "Bearer token-b": "account-b"}[request.headers["authorization"]] - return httpx.Response(200, json={"jsonrpc": "2.0", "id": payload.id, "result": {"prompts": [{"name": name}]}}) + return httpx2.Response(200, json={"jsonrpc": "2.0", "id": payload.id, "result": {"prompts": [{"name": name}]}}) - with respx.mock(base_url="https://discovery.example") as router: - router.route().mock(side_effect=respond) + with _mcp_upstream(respond): for manager in managers: assert [item.name for item in await manager.get_prompts_from_server(server, user)] == ["discovery-account-a"] assert upstream.initializes == 2 @@ -13403,8 +13417,7 @@ async def test_discovery_resolves_stored_oauth_for_the_requesting_user() -> None ) user: Final = UserAPIKeyAuth(user_id="requesting-user") upstream: Final = _DiscoveryUpstream() - with respx.mock(base_url="https://discovery.example") as router: - router.route().mock(side_effect=upstream.respond) + with _mcp_upstream(upstream.respond): assert len(await manager.get_prompts_from_server(server, user)) == 1 assert len(await manager.get_prompts_from_server(server, user)) == 1 assert store.calls == (("requesting-user", "discovery"), ("requesting-user", "discovery")) @@ -13506,7 +13519,7 @@ class TestProtectedCredentialPreparation: if dispatch == "managed" else await _handle_local_mcp_tool(add_server_prefix_to_name("echo", get_server_prefix(server)), {}) ) - assert result.isError is True + assert result.is_error is True assert "requires a usable upstream credential" in result.content[0].text assert destination.call_count == 0 @@ -13937,5 +13950,5 @@ async def test_request_selected_during_guardrail_runs_concurrently_with_tool(mon ), timeout=5) assert tool_started.is_set() assert guardrail_started.is_set() is selected - assert result.isError is False + assert result.is_error is False assert result.content[0].text == "executed" diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sigv4_auth.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sigv4_auth.py index e814425c9a2..66d5f0e56f9 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sigv4_auth.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sigv4_auth.py @@ -1,7 +1,7 @@ """ Tests for AWS SigV4 authentication in MCP client. -Tests the MCPSigV4Auth httpx.Auth subclass that enables per-request +Tests the MCPSigV4Auth httpx2.Auth subclass that enables per-request SigV4 signing for Bedrock AgentCore MCP servers, plus DB/UI path tests for credential encryption, merge-on-update, and build_from_table. """ @@ -11,7 +11,7 @@ import json import pytest from unittest.mock import patch, MagicMock, AsyncMock -import httpx +import httpx2 from litellm.experimental_mcp_client.client import MCPSigV4Auth, MCPClient from litellm.types.mcp import MCPAuth, MCPTransport @@ -103,7 +103,7 @@ class TestMCPSigV4Auth: aws_service_name="bedrock-agentcore", ) - request = httpx.Request( + request = httpx2.Request( method="POST", url="https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/test/invocations", headers={"Content-Type": "application/json"}, @@ -128,13 +128,13 @@ class TestMCPSigV4Auth: aws_region_name="us-east-1", ) - request1 = httpx.Request( + request1 = httpx2.Request( method="POST", url="https://example.com/mcp", headers={"Content-Type": "application/json"}, content=b'{"jsonrpc":"2.0","method":"tools/list","id":1}', ) - request2 = httpx.Request( + request2 = httpx2.Request( method="POST", url="https://example.com/mcp", headers={"Content-Type": "application/json"}, @@ -156,7 +156,7 @@ class TestMCPSigV4Auth: aws_region_name="us-east-1", ) - request = httpx.Request( + request = httpx2.Request( method="POST", url="https://example.com/mcp", headers={"Content-Type": "application/json"}, @@ -265,7 +265,7 @@ class TestMCPSigV4AssumeRole: aws_service_name="bedrock-agentcore", ) - request = httpx.Request( + request = httpx2.Request( method="POST", url="https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/test/invocations", headers={"Content-Type": "application/json"}, @@ -306,7 +306,7 @@ class TestMCPClientSigV4Integration: def test_mcp_client_stores_aws_auth(self): """MCPClient stores the aws_auth parameter.""" - mock_auth = MagicMock(spec=httpx.Auth) + mock_auth = MagicMock(spec=httpx2.Auth) client = MCPClient( server_url="https://example.com/mcp", transport_type=MCPTransport.http, @@ -330,7 +330,7 @@ class TestMCPClientSigV4Integration: factory = client._create_httpx_client_factory() httpx_client = factory( headers={"Content-Type": "application/json"}, - timeout=httpx.Timeout(30.0), + timeout=httpx2.Timeout(30.0), ) # Verify the auth object was actually wired into the httpx client @@ -342,7 +342,7 @@ class TestMCPClientSigV4Integration: aws_access_key_id="AKIAIOSFODNN7EXAMPLE", aws_secret_access_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", ) - explicit_auth = MagicMock(spec=httpx.Auth) + explicit_auth = MagicMock(spec=httpx2.Auth) client = MCPClient( server_url="https://example.com/mcp", @@ -353,7 +353,7 @@ class TestMCPClientSigV4Integration: factory = client._create_httpx_client_factory() httpx_client = factory( headers={"Content-Type": "application/json"}, - timeout=httpx.Timeout(30.0), + timeout=httpx2.Timeout(30.0), auth=explicit_auth, ) @@ -370,7 +370,7 @@ class TestMCPClientSigV4Integration: factory = client._create_httpx_client_factory() httpx_client = factory( headers={"Content-Type": "application/json"}, - timeout=httpx.Timeout(30.0), + timeout=httpx2.Timeout(30.0), ) # No auth should be set when aws_auth is not configured assert httpx_client._auth is None diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_tool_search.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_tool_search.py index b8935d07774..5236d0e9ee5 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_tool_search.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_tool_search.py @@ -40,7 +40,7 @@ from litellm.types.mcp import MCPToolSearchSettings def _make_tools(specs: list[tuple[str, str]]) -> tuple[Tool, ...]: return tuple( - Tool(name=name, description=desc, inputSchema={"type": "object", "properties": {}}) for name, desc in specs + Tool(name=name, description=desc, input_schema={"type": "object", "properties": {}}) for name, desc in specs ) @@ -62,17 +62,17 @@ SAMPLE_TOOLS = _make_tools( FX_TOOL = Tool( name="treasury-get_rates", description="Get foreign exchange rates for a currency pair", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, ) WEATHER_TOOL = Tool( name="weather-forecast", description="Get the weather forecast for a city", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, ) CALENDAR_TOOL = Tool( name="calendar-create_event", description="Create a calendar event", - inputSchema={"type": "object", "properties": {}}, + input_schema={"type": "object", "properties": {}}, ) CATALOG = (FX_TOOL, WEATHER_TOOL, CALENDAR_TOOL) @@ -113,7 +113,7 @@ class TestSearchMcpTools: assert _names(results) == [FX_TOOL.name, WEATHER_TOOL.name, CALENDAR_TOOL.name] assert not isinstance(results, EmbeddingFailed) assert results[0]["score"] > results[1]["score"] > results[2]["score"] - assert results[0]["inputSchema"] == FX_TOOL.inputSchema + assert results[0]["inputSchema"] == FX_TOOL.input_schema @pytest.mark.asyncio async def test_similarity_threshold_drops_weak_matches(self) -> None: @@ -313,10 +313,10 @@ class TestGetVirtualToolDefinitions: for definition in get_virtual_tool_definitions(): tool = Tool.model_validate(definition) - required_arguments = {name: "x" for name in tool.inputSchema["required"]} - validate(instance=required_arguments, schema=tool.inputSchema) + required_arguments = {name: "x" for name in tool.input_schema["required"]} + validate(instance=required_arguments, schema=tool.input_schema) with pytest.raises(ValidationError): - validate(instance={}, schema=tool.inputSchema) + validate(instance={}, schema=tool.input_schema) def test_all_tools_have_description(self) -> None: for tool in get_virtual_tool_definitions(): @@ -562,7 +562,7 @@ class TestCallToolRestApiVirtualTools: mock_tool = MagicMock() mock_tool.name = "github-create_issue" mock_tool.description = "Create a GitHub issue" - mock_tool.inputSchema = {"type": "object", "properties": {}} + mock_tool.input_schema= {"type": "object", "properties": {}} with patch( "litellm.proxy._experimental.mcp_server.server._list_mcp_tools", @@ -604,7 +604,7 @@ class TestCallToolRestApiVirtualTools: fake_result = CallToolResult( content=[TextContent(type="text", text="Issue created")], - isError=False, + is_error=False, ) with ( @@ -633,7 +633,7 @@ class TestCallToolRestApiVirtualTools: mock_fire_logging.assert_awaited_once() assert mock_execute.await_args.kwargs["name"] == "github-create_issue" - assert result.isError is False + assert result.is_error is False assert result.content[0].text == "Issue created" @pytest.mark.asyncio @@ -654,7 +654,7 @@ class TestCallToolRestApiVirtualTools: } ) - fake_result = CallToolResult(content=[TextContent(type="text", text="ok")], isError=False) + fake_result = CallToolResult(content=[TextContent(type="text", text="ok")], is_error=False) with ( patch( @@ -730,7 +730,7 @@ class TestCallToolRestApiVirtualTools: ): result = await self._get_call_fn()(request=request, user_api_key_dict=user_api_key_dict) - assert result.isError is False + assert result.is_error is False assert mock_search.await_args.kwargs["user_api_key_dict"] is user_api_key_dict assert json.loads(result.content[0].text) == [ { @@ -758,7 +758,7 @@ class TestCallToolRestApiVirtualTools: request = self._make_request( {"name": SKILL_SEARCH_TOOL_NAME, "arguments": {"query": "translate a document", "top_k": "not-a-number"}} ) - fake_result = CallToolResult(content=[TextContent(type="text", text="[]")], isError=False) + fake_result = CallToolResult(content=[TextContent(type="text", text="[]")], is_error=False) with patch( # test-quality-ok: the embedding router only resolves via proxy_server globals, no injection seam "litellm.proxy._experimental.mcp_server.tool_search.handle_skill_search", new_callable=AsyncMock, @@ -766,7 +766,7 @@ class TestCallToolRestApiVirtualTools: ) as mock_search: result = await self._get_call_fn()(request=request, user_api_key_dict=user_api_key_dict) - assert result.isError is False + assert result.is_error is False assert mock_search.await_args.kwargs["top_k"] == DEFAULT_SKILL_SEARCH_TOP_K assert mock_search.await_args.kwargs["query"] == "translate a document" @@ -790,7 +790,7 @@ class TestCallToolRestApiVirtualTools: ): result = await self._get_call_fn()(request=request, user_api_key_dict=user_api_key_dict) - assert result.isError is True + assert result.is_error is True assert result.content[0].text == "set agent_search_embedding_model" def _semantic_request(self, query: str = "FX") -> MagicMock: @@ -835,7 +835,7 @@ class TestCallToolRestApiVirtualTools: assert mock_list.await_args.kwargs["user_api_key_auth"] is user_api_key_dict assert key_limits.pre_call_hook.await_args.kwargs["call_type"] == "aembedding" assert key_limits.pre_call_hook.await_args.kwargs["data"]["model"] == "emb" - assert result.isError is False + assert result.is_error is False assert [t["name"] for t in json.loads(result.content[0].text)] == [FX_TOOL.name] @pytest.mark.asyncio @@ -846,7 +846,7 @@ class TestCallToolRestApiVirtualTools: "litellm.proxy.proxy_server.llm_router", None ): result = await self._get_call_fn()(request=self._semantic_request(), user_api_key_dict=user_api_key_dict) - assert result.isError is True + assert result.is_error is True assert "mcp_tool_search.embedding_model" in result.content[0].text @pytest.mark.asyncio @@ -856,7 +856,7 @@ class TestCallToolRestApiVirtualTools: monkeypatch.setattr(litellm, "mcp_tool_search", {"top_k": 0}) user_api_key_dict = UserAPIKeyAuth(api_key="k", object_permission=_make_perm(mcp_tool_search_enabled=True)) result = await self._get_call_fn()(request=self._semantic_request(), user_api_key_dict=user_api_key_dict) - assert result.isError is True + assert result.is_error is True assert "top_k" in result.content[0].text @pytest.mark.asyncio @@ -920,7 +920,7 @@ class TestDispatchVirtualMcpTool: client_ip=None, ) assert result is not None - assert result.isError is True + assert result.is_error is True @pytest.mark.asyncio async def test_routes_search_with_client_ip(self) -> None: @@ -977,7 +977,7 @@ class TestDispatchVirtualMcpTool: name=AGENT_SEARCH_TOOL_NAME, arguments={"query": "x"}, user_api_key_auth=uak, client_ip=None ) assert result is not None - assert result.isError is True + assert result.is_error is True @pytest.mark.asyncio async def test_routes_call_with_client_ip(self) -> None: @@ -1073,7 +1073,7 @@ class TestDispatchVirtualMcpTool: ) uak = UserAPIKeyAuth(api_key="k", object_permission=_make_perm(mcp_tool_search_enabled=True)) - fake = CallToolResult(content=[TextContent(type="text", text="ok")], isError=False) + fake = CallToolResult(content=[TextContent(type="text", text="ok")], is_error=False) with ( patch( "litellm.proxy._experimental.mcp_server.server._get_allowed_mcp_servers", @@ -1164,7 +1164,7 @@ class TestCaptureHostProgressCallback: ) host = MagicMock() - host.request_context.meta.progressToken = None + host.request_context.meta.progress_token = None assert _capture_host_progress_callback(host) is None def test_returns_callable_when_token_present(self) -> None: @@ -1173,7 +1173,7 @@ class TestCaptureHostProgressCallback: ) host = MagicMock() - host.request_context.meta.progressToken = "tok12345" + host.request_context.meta.progress_token = "tok12345" host.request_context.session = MagicMock() assert callable(_capture_host_progress_callback(host)) @@ -1183,7 +1183,7 @@ class TestCaptureHostProgressCallback: ) host = MagicMock() - host.request_context.meta.progressToken = 12345 + host.request_context.meta.progress_token = 12345 host.request_context.session = MagicMock() assert callable(_capture_host_progress_callback(host)) @@ -1193,7 +1193,7 @@ class TestCaptureHostProgressCallback: ) host = MagicMock() - host.request_context.meta.progressToken = 0 + host.request_context.meta.progress_token = 0 host.request_context.session = MagicMock() assert callable(_capture_host_progress_callback(host)) @@ -1204,7 +1204,7 @@ class TestCaptureHostProgressCallback: ) host = MagicMock() - host.request_context.meta.progressToken = 12345 + host.request_context.meta.progress_token = 12345 session = AsyncMock() host.request_context.session = session @@ -1270,7 +1270,7 @@ class TestMcpServerToolCallErrorHandling: arguments={"tool_name": "other-server-tool", "arguments": {}}, ) - assert result.isError is True + assert result.is_error is True assert "User not allowed to call this tool" in result.content[0].text diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_toolset_scope.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_toolset_scope.py index 519acc241c6..c4e1f1e4a6e 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_toolset_scope.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_toolset_scope.py @@ -285,7 +285,7 @@ class TestToolsetPrefixResolution: live_tools = [ MCPTool( name=add_server_prefix_to_name(name, prefix), - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ) for name in ("read_wiki_contents", "read_wiki_structure", "not_granted") ] @@ -414,7 +414,7 @@ class TestToolsetPrefixResolution: live_tools = [ MCPTool( name=add_server_prefix_to_name(name, prefix), - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ) for name in (granted, sibling) ] @@ -472,7 +472,7 @@ class TestToolsetPrefixResolution: live_tools = [ MCPTool( name=add_server_prefix_to_name(granted, prefix), - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ) ] diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_tool_auth.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_tool_auth.py index 334bee9800c..ac716bace3c 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_tool_auth.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_tool_auth.py @@ -459,7 +459,7 @@ async def test_legacy_local_tool_fallback_still_dispatches_entitled_caller( user_api_key_auth=user, ) - assert result.isError is False + assert result.is_error is False assert executed == [{}] assert "legacy local tool ran" in result.content[0].text @@ -663,12 +663,12 @@ async def test_local_dispatch_reports_the_outcome_instead_of_success(failure: st failure may propagate. `_handle_local_mcp_tool` used to catch every exception and return it as TextContent, and both of - its callers then stamped `isError=False`, so an upstream rejection was served as tool output and + its callers then stamped `is_error=False`, so an upstream rejection was served as tool output and `extract_mcp_tool_result_error_message` logged the request as a success. The two kinds are split by consequence. `MCPUpstreamAuthError` propagates because both renderers know it: the streamable path names the status and the REST path relays a real 401 with the - upstream's WWW-Authenticate. Anything else is reported as `isError=True` right here, because + upstream's WWW-Authenticate. Anything else is reported as `is_error=True` right here, because `call_tool_rest_api` turns an unrecognized exception into HTTP 500 and an upstream 403 or 429 is not a gateway crash. """ @@ -729,7 +729,7 @@ async def test_local_dispatch_reports_the_outcome_instead_of_success(failure: st result = await call # A non-auth upstream failure stays a 200 with isError, so REST does not report it as a gateway 500 - assert result.isError is True + assert result.is_error is True assert "upstream returned HTTP 429" in result.content[0].text diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py index 4ec4ae31ca6..810cf9fec5d 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py @@ -963,7 +963,7 @@ class TestTestToolsList: class QuickClient: async def list_tools(self, raise_on_error=False): - return [MCPTool(name="quick_tool", description="q", inputSchema={})] + return [MCPTool(name="quick_tool", description="q", input_schema={})] async def fake_execute( request, @@ -1008,7 +1008,7 @@ class TestTestToolsList: async def list_tools(self, raise_on_error=False): await asyncio.sleep(0.2) - return [MCPTool(name="slow_tool", description="s", inputSchema={})] + return [MCPTool(name="slow_tool", description="s", input_schema={})] async def fake_execute( request, @@ -1512,7 +1512,7 @@ class TestListToolsRestAPI: MCPTool( name="first_page_tool", description="First page tool", - inputSchema={}, + input_schema={}, ) ], nextCursor="page-2", @@ -1522,7 +1522,7 @@ class TestListToolsRestAPI: MCPTool( name="second_page_tool", description="Second page tool", - inputSchema={}, + input_schema={}, ) ] ), @@ -3177,7 +3177,7 @@ async def test_request_selected_tool_specific_guardrail_applies_to_virtual_execu upstream.assert_not_awaited() else: result: Final = await rest_endpoints.call_tool_rest_api(request, user_api_key_dict=caller) - assert result.isError is False + assert result.is_error is False upstream.assert_awaited_once() assert upstream.await_args.kwargs == {"q": "redacted" if selected else "confidential"} @@ -3198,7 +3198,7 @@ class TestGetToolsForSingleServer: def __init__(self, name, description): self.name = name self.description = description - self.inputSchema = {} + self.input_schema= {} mock_tools = [ MockTool("tool1", "First tool"), @@ -3259,7 +3259,7 @@ class TestGetToolsForSingleServer: def __init__(self, name, description): self.name = name self.description = description - self.inputSchema = {} + self.input_schema= {} mock_tools = [ MockTool("tool1", "First tool"), @@ -3307,7 +3307,7 @@ class TestGetToolsForSingleServer: def __init__(self, name, description): self.name = name self.description = description - self.inputSchema = {} + self.input_schema= {} mock_tools = [ MockTool("tool1", "First tool"), @@ -3360,7 +3360,7 @@ class TestGetToolsForSingleServer: def __init__(self, name, description): self.name = name self.description = description - self.inputSchema = {} + self.input_schema= {} mock_tools = [ MockTool("tool1", "First tool"), @@ -3413,7 +3413,7 @@ class TestGetToolsForSingleServer: def __init__(self, name, description): self.name = name self.description = description - self.inputSchema = {} + self.input_schema= {} mock_tools = [ MockTool("tool1", "First tool"), @@ -3475,7 +3475,7 @@ class TestGetToolsForSingleServer: def __init__(self, name): self.name = name self.description = name - self.inputSchema = {} + self.input_schema= {} mock_tools = [MockTool("tool1"), MockTool("tool2"), MockTool("tool3")] @@ -3903,11 +3903,11 @@ class TestConnectionErrorMessage: assert "secret" not in message def test_closed_connection_explains_incomplete_request(self) -> None: - from mcp import McpError + from mcp import MCPError from mcp.types import ErrorData message: Final = rest_endpoints._connection_error_message( - McpError(ErrorData(code=-32000, message="Connection closed", data="secret-data")), None, 30 + MCPError(code=-32000, message="Connection closed", data="secret-data"), None, 30 ) assert "connection was closed before the request completed" in message assert "secret" not in message @@ -3920,7 +3920,7 @@ class TestConnectionErrorMessage: @pytest.mark.parametrize("sdk_timeout", [True, False]) @pytest.mark.parametrize("read_timeout", [0, 1]) async def test_timeout_message_uses_the_deadline_that_expired(self, sdk_timeout: bool, read_timeout: int) -> None: - from mcp import McpError + from mcp import MCPError from mcp.types import ErrorData async def operation(client: rest_endpoints.MCPClient) -> dict[str, object]: @@ -3930,8 +3930,8 @@ class TestConnectionErrorMessage: if not sdk_timeout: raise try: - raise McpError(ErrorData(code=408, message="secret-sdk-timeout")) from elapsed - except McpError as sdk_error: + raise MCPError(code=408, message="secret-sdk-timeout") from elapsed + except MCPError as sdk_error: raise TimeoutError() from sdk_error payload: Final = NewMCPServerRequest( @@ -3947,11 +3947,11 @@ class TestConnectionErrorMessage: assert "reference" in message.lower() def test_sdk_session_terminated_explains_endpoint_and_retry(self) -> None: - from mcp.shared.exceptions import McpError + from mcp.shared.exceptions import MCPError from mcp.types import ErrorData message: Final = rest_endpoints._connection_error_message( - McpError(ErrorData(code=32600, message="Session terminated")), "https://example.com/mcp", 30.0 + MCPError(code=32600, message="Session terminated"), "https://example.com/mcp", 30.0 ) assert "session was terminated" in message @@ -3962,11 +3962,11 @@ class TestConnectionErrorMessage: @pytest.mark.parametrize("code", [-32700, -32601, -32602, -32603, -32000, 32600, 408]) def test_rpc_errors_include_code_without_echoing_upstream_data(self, code: int) -> None: - from mcp.shared.exceptions import McpError + from mcp.shared.exceptions import MCPError from mcp.types import ErrorData message: Final = rest_endpoints._connection_error_message( - McpError(ErrorData(code=code, message="secret-message", data={"token": "secret-data"})), + MCPError(code=code, message="secret-message", data={"token": "secret-data"}), "https://example.com/secret-path?token=secret-query", 30.0, ) @@ -4138,7 +4138,7 @@ class TestToolResponseMcpInfoEnrichment: MCPTool( name="get_issue", description="Fetch a Jira issue", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ) ] @@ -4168,7 +4168,7 @@ class TestToolResponseMcpInfoEnrichment: MCPTool( name="ping", description="Ping", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ) ] @@ -4210,8 +4210,8 @@ class TestRestListToolsetFiltering: stub_server.mcp_info = {"server_name": "stubtools"} upstream_tools = [ - MCPTool(name="lookup_status", inputSchema={"type": "object"}), - MCPTool(name="delete_everything", inputSchema={"type": "object"}), + MCPTool(name="lookup_status", input_schema={"type": "object"}), + MCPTool(name="delete_everything", input_schema={"type": "object"}), ] key_object_permission = MagicMock() diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py index f0b4e94f72f..64ec6d2e78e 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py @@ -42,52 +42,52 @@ async def test_semantic_filter_basic_filtering(): MCPTool( name="gmail_send", description="Send an email via Gmail", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), MCPTool( name="outlook_send", description="Send an email via Outlook", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), MCPTool( name="calendar_create", description="Create a calendar event", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), MCPTool( name="calendar_update", description="Update a calendar event", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), MCPTool( name="email_read", description="Read emails from inbox", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), MCPTool( name="email_delete", description="Delete an email", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), MCPTool( name="calendar_delete", description="Delete a calendar event", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), MCPTool( name="email_search", description="Search for emails", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), MCPTool( name="calendar_list", description="List calendar events", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), MCPTool( name="email_forward", description="Forward an email to someone", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ), ] @@ -170,7 +170,7 @@ async def test_semantic_filter_top_k_limiting(): MCPTool( name=f"tool_{i}", description=f"Tool number {i} for testing", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ) for i in range(20) ] @@ -228,7 +228,7 @@ async def test_semantic_filter_disabled(): tools = [ MCPTool( - name=f"tool_{i}", description=f"Tool {i}", inputSchema={"type": "object"} + name=f"tool_{i}", description=f"Tool {i}", input_schema={"type": "object"} ) for i in range(10) ] @@ -375,7 +375,7 @@ async def test_semantic_filter_hook_triggers_on_completion(): # Prepare data - completion request with tools tools = [ MCPTool( - name=f"tool_{i}", description=f"Tool {i}", inputSchema={"type": "object"} + name=f"tool_{i}", description=f"Tool {i}", input_schema={"type": "object"} ) for i in range(10) ] @@ -508,7 +508,7 @@ async def test_semantic_filter_hook_preserves_native_tools(): MCPTool( name=f"mcp_tool_{i}", description=f"MCP tool {i}", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ) for i in range(5) ] @@ -624,7 +624,7 @@ async def test_semantic_filter_hook_all_native_tools(): MCPTool( name="some_mcp_tool", description="An MCP tool", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ) ] @@ -741,7 +741,7 @@ async def test_semantic_filter_hook_responses_api_name_collision(): MCPTool( name="github-search", description="Search GitHub repos", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ) ] filter_instance._build_router(mcp_tools) @@ -836,7 +836,7 @@ async def test_semantic_filter_hook_filters_expanded_litellm_proxy_tools(): MCPTool( name=f"srv-tool_{i}", description=f"Registry tool {i}", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ) for i in range(5) ] @@ -958,7 +958,7 @@ async def test_semantic_filter_hook_narrows_mcp_reference_for_chat_completions() MCPTool( name=f"srv-tool_{i}", description=f"Registry tool {i}", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ) for i in range(5) ] @@ -1065,7 +1065,7 @@ async def test_semantic_filter_hook_zero_matches_exposes_all_tools_on_both_paths MCPTool( name=f"srv-tool_{i}", description=f"Registry tool {i}", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ) for i in range(3) ] @@ -1182,7 +1182,7 @@ async def test_semantic_filter_hook_filters_expanded_tools_with_string_input(): MCPTool( name=f"srv-tool_{i}", description=f"Registry tool {i}", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ) for i in range(5) ] @@ -1326,12 +1326,12 @@ async def test_semantic_filter_hook_preserves_tool_order(): mcp_tool_a = MCPTool( name="github-search", description="Search GitHub", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ) mcp_tool_b = MCPTool( name="github-issue", description="Create GitHub issue", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ) filter_instance._build_router([mcp_tool_a, mcp_tool_b]) @@ -1683,7 +1683,7 @@ async def test_semantic_filter_fails_closed_on_query_time_context_window_error() filter_instance = _make_context_window_filter(state) tools = [ - MCPTool(name=f"tool_{i}", description=f"Tool {i}", inputSchema={"type": "object"}) + MCPTool(name=f"tool_{i}", description=f"Tool {i}", input_schema={"type": "object"}) for i in range(5) ] filter_instance._build_router(tools) @@ -1716,7 +1716,7 @@ async def test_semantic_filter_records_build_time_context_window_error(): filter_instance = _make_context_window_filter(state) tools = [ - MCPTool(name=f"tool_{i}", description=f"Tool {i}", inputSchema={"type": "object"}) + MCPTool(name=f"tool_{i}", description=f"Tool {i}", input_schema={"type": "object"}) for i in range(5) ] filter_instance._build_router(tools) @@ -1750,7 +1750,7 @@ async def test_semantic_filter_hook_fails_closed_on_context_window_error(): filter_instance = _make_context_window_filter(state) tools = [ - MCPTool(name=f"tool_{i}", description=f"Tool {i}", inputSchema={"type": "object"}) + MCPTool(name=f"tool_{i}", description=f"Tool {i}", input_schema={"type": "object"}) for i in range(5) ] filter_instance._build_router(tools) @@ -1798,7 +1798,7 @@ async def test_semantic_filter_hook_fails_closed_on_expanded_tools_context_windo filter_instance = _make_context_window_filter(state) registry_tools = [ - MCPTool(name=f"srv-tool_{i}", description=f"Registry tool {i}", inputSchema={"type": "object"}) + MCPTool(name=f"srv-tool_{i}", description=f"Registry tool {i}", input_schema={"type": "object"}) for i in range(5) ] filter_instance._build_router(registry_tools) @@ -1862,7 +1862,7 @@ async def test_semantic_filter_hook_ignores_build_error_for_native_only_tools(): filter_instance = _make_context_window_filter(state) mcp_tools = [ - MCPTool(name=f"tool_{i}", description=f"Tool {i}", inputSchema={"type": "object"}) + MCPTool(name=f"tool_{i}", description=f"Tool {i}", input_schema={"type": "object"}) for i in range(3) ] filter_instance._build_router(mcp_tools) @@ -2019,7 +2019,7 @@ def _linear_issue_tool(): return MCPTool( name="linear_stub-get_issue", description="Get a Linear issue (ticket) by its identifier such as LIT-1234", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ) @@ -2027,7 +2027,7 @@ def _linear_list_tool(): return MCPTool( name="linear_stub-list_issues", description="List Linear issues (tickets) in the workspace", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ) @@ -2035,7 +2035,7 @@ def _weather_tool(): return MCPTool( name="weather_stub-get_weather", description="Get the current weather conditions for a city", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ) @@ -2135,8 +2135,8 @@ async def test_request_time_context_window_error_is_request_scoped(): state = {"raise_context_error": True} filter_instance = _make_context_window_filter(state) tools = [ - MCPTool(name="tool_a", description="Tool A", inputSchema={"type": "object"}), - MCPTool(name="tool_b", description="Tool B", inputSchema={"type": "object"}), + MCPTool(name="tool_a", description="Tool A", input_schema={"type": "object"}), + MCPTool(name="tool_b", description="Tool B", input_schema={"type": "object"}), ] with pytest.raises(SemanticToolFilterContextWindowError): @@ -2171,7 +2171,7 @@ async def test_foreign_index_routes_cannot_displace_available_tools(): MCPTool( name=f"other_user-linear_tool_{i}", description=f"Get a Linear issue variant {i}", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ) for i in range(6) ] @@ -2180,7 +2180,7 @@ async def test_foreign_index_routes_cannot_displace_available_tools(): my_kanban = MCPTool( name="mine-kanban_board", description="Manage kanban board cards", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ) filtered = await filter_instance.filter_tools( query="what is Linear ticket LIT-3794 about", @@ -2204,7 +2204,7 @@ async def test_top_k_above_router_default_is_respected(): MCPTool( name=f"linear_stub-tool_{i}", description=f"Work with Linear issues part {i}", - inputSchema={"type": "object"}, + input_schema={"type": "object"}, ) for i in range(6) ] diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_short_mcp_tool_prefix.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_short_mcp_tool_prefix.py index 941e5deee93..8528f20fe89 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_short_mcp_tool_prefix.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_short_mcp_tool_prefix.py @@ -268,8 +268,8 @@ class TestIsToolNamePrefixedBoundary: def _stub_tools() -> List[MCPTool]: return [ - MCPTool(name="get_repo", description="", inputSchema={"type": "object"}), - MCPTool(name="list_issues", description="", inputSchema={"type": "object"}), + MCPTool(name="get_repo", description="", input_schema={"type": "object"}), + MCPTool(name="list_issues", description="", input_schema={"type": "object"}), ]