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>
This commit is contained in:
joshua 2026-09-18 22:13:18 +00:00
parent 5dc01319d7
commit 545bbeb001
31 changed files with 632 additions and 637 deletions

View file

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

View file

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

View file

@ -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"}},
},

View file

@ -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"},
),
]

View file

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

View file

@ -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"}
),
]

View file

@ -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", "<stripped>")))
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)

View file

@ -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"],

View file

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

View file

@ -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", "<EMAIL_ADDRESS>")
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 <EMAIL_ADDRESS>"
assert returned.structuredContent == {"contact": {"email": "<EMAIL_ADDRESS>"}, "balance": 42.0}
assert returned.structured_content== {"contact": {"email": "<EMAIL_ADDRESS>"}, "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", "<EMAIL_ADDRESS>")
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": "<EMAIL_ADDRESS>"}]}
assert returned.structured_content== {"records": [{"email": "<EMAIL_ADDRESS>"}]}
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", "<EMAIL_ADDRESS>")
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", "<EMAIL_ADDRESS>")
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", "<PHONE_NUMBER>")
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", "<EMAIL_ADDRESS>")
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 <EMAIL_ADDRESS>"
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}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"] == ""

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"},
)
]

View file

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

View file

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

View file

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

View file

@ -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"}),
]