mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-22 00:31:44 +00:00
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:
parent
5dc01319d7
commit
545bbeb001
31 changed files with 632 additions and 637 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"}},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"] == ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
)
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"}),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue