mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-30 06:43:27 +00:00
* refactor(mcp): drop the experimental label from the MCP server and client paths Move litellm/proxy/_experimental/mcp_server to litellm/proxy/mcp_server and litellm/experimental_mcp_client to litellm/mcp_client, with the mirrored tests moved under tests/unit/proxy/mcp_server and tests/unit/mcp_client. Both old packages stay as shims that warn with DeprecationWarning, re-export the new package's public names and alias every submodule in sys.modules so deep imports keep resolving to the same objects. The OpenAPI docs gate now puts the merge base's enterprise package on PYTHONPATH so its base counts do not mix the checked-out enterprise code with the base litellm tree Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci(mcp): keep tests/unit/proxy/_experimental as a sharded root for the shim tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(mcp): alias namespace subpackages and bind attributes in the deprecated MCP shims pkgutil.walk_packages skipped mcp_server/auth because it has no __init__.py, so old imports of litellm.proxy._experimental.mcp_server.auth.* failed, and sys.modules entries alone left litellm.experimental_mcp_client.tools unreachable as an attribute. Walk the package on disk and setattr each alias on its old parent. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
127 lines
5.9 KiB
Python
127 lines
5.9 KiB
Python
import json
|
|
import queue
|
|
import uuid
|
|
from typing import Final
|
|
|
|
import pytest
|
|
from integration._support.client import Gateway
|
|
from integration._support.mcp import McpPeer, call_tool, register_mcp, tool_names
|
|
from integration._support.wire import Reply, Request, wire_server
|
|
|
|
pytestmark: Final = pytest.mark.shares_worker_state
|
|
|
|
|
|
@pytest.mark.covers("other.mcp.errors.protocol_and_malformed_results_cannot_be_empty_success")
|
|
def test_jsonrpc_error_and_malformed_tool_result_remain_errors(gateway: Gateway) -> None:
|
|
def provider(request: Request) -> Reply:
|
|
if request.method != "POST":
|
|
return Reply(status=405)
|
|
body: Final = json.loads(request.body)
|
|
method: Final = body["method"]
|
|
if "id" not in body:
|
|
return Reply(status=202)
|
|
base: Final = {"jsonrpc": "2.0", "id": body["id"]}
|
|
if method == "initialize":
|
|
return Reply(
|
|
body=json.dumps(
|
|
{
|
|
**base,
|
|
"result": {
|
|
"protocolVersion": body["params"]["protocolVersion"],
|
|
"capabilities": {"tools": {}},
|
|
"serverInfo": {"name": "synthetic-protocol-peer", "version": "1"},
|
|
},
|
|
}
|
|
).encode()
|
|
)
|
|
if method == "tools/list":
|
|
return Reply(
|
|
body=json.dumps(
|
|
{
|
|
**base,
|
|
"result": {
|
|
"tools": [
|
|
{"name": name, "inputSchema": {"type": "object"}}
|
|
for name in ("add", "multiply", "fail")
|
|
]
|
|
},
|
|
}
|
|
).encode()
|
|
)
|
|
assert method == "tools/call"
|
|
name: Final = body["params"]["name"]
|
|
if name == "fail":
|
|
return Reply(
|
|
body=json.dumps({**base, "error": {"code": -32042, "message": "synthetic JSON-RPC error"}}).encode()
|
|
)
|
|
result: Final = (
|
|
{"content": "synthetic malformed content"}
|
|
if name == "multiply"
|
|
else {"content": [{"type": "text", "text": "8"}], "isError": False}
|
|
)
|
|
return Reply(body=json.dumps({**base, "result": result}).encode())
|
|
|
|
with wire_server(provider) as wire, gateway.scenario() as scenario:
|
|
identity: Final = register_mcp(
|
|
scenario, McpPeer(wire.url + "/mcp", queue.Queue()), "integration" + uuid.uuid4().hex
|
|
)
|
|
key: Final = scenario.key(object_permission={"mcp_servers": [identity]})
|
|
names: Final = tool_names(gateway, key, identity)
|
|
for name, expected in (("fail", "synthetic JSON-RPC error"), ("multiply", "validation")):
|
|
wire.drain()
|
|
response: Final = call_tool(gateway, key, identity, names[name], {})
|
|
assert response.status_code == 200 and response.json()["isError"] is True, response.text
|
|
assert expected.lower() in response.json()["content"][0]["text"].lower(), response.text
|
|
assert (
|
|
len(
|
|
tuple(
|
|
item
|
|
for item in wire.drain()
|
|
if item.method == "POST" and json.loads(item.body).get("method") == "tools/call"
|
|
)
|
|
)
|
|
== 1
|
|
)
|
|
control: Final = call_tool(gateway, key, identity, names["add"], {"a": 3, "b": 5})
|
|
assert control.status_code == 200 and control.json()["isError"] is False, control.text
|
|
assert control.json()["content"][0]["text"] == "8"
|
|
|
|
|
|
@pytest.mark.parametrize("ingress", ("http", "sse"))
|
|
def test_configured_revision_blocks_unadvertised_handshake_and_keeps_allowed_control(
|
|
gateway: Gateway, tmp_path, ingress: str
|
|
) -> None:
|
|
import asyncio
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
from integration._support.mcp import mcp_peer
|
|
from integration._support.process import owned_proxy
|
|
from mcp import MCPError
|
|
from mcp.types import CallToolRequestParams
|
|
|
|
from litellm.mcp_client.client import MCPClient
|
|
from litellm.types.mcp import MCPTransport
|
|
|
|
with mcp_peer() as upstream, gateway.scenario() as scenario:
|
|
alias: Final = "restricted" + uuid.uuid4().hex[:8]
|
|
identity: Final = register_mcp(scenario, upstream, alias)
|
|
key: Final = scenario.key(object_permission={"mcp_servers": [identity]})
|
|
config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
|
|
config["general_settings"]["mcp_advertised_versions"] = ["2024-11-05"]
|
|
config_path: Final = tmp_path / "restricted.yaml"
|
|
config_path.write_text(yaml.safe_dump(config))
|
|
with owned_proxy(gateway, tmp_path, {"DISABLE_SCHEMA_UPDATE": "true"}, config=config_path) as restricted:
|
|
endpoint: Final = str(restricted.client.base_url).rstrip("/") + ("/mcp/sse" if ingress == "sse" else "/mcp")
|
|
headers: Final = {"Authorization": f"Bearer {key}", "x-mcp-servers": identity}
|
|
denied: Final = MCPClient(server_url=endpoint, transport_type=MCPTransport(ingress), protocol_version="2025-11-25", extra_headers=headers)
|
|
allowed: Final = MCPClient(server_url=endpoint, transport_type=MCPTransport(ingress), protocol_version="2024-11-05", extra_headers=headers)
|
|
|
|
async def exercise() -> None:
|
|
with pytest.raises(MCPError, match="Unsupported MCP protocol version"):
|
|
await denied.list_tools(raise_on_error=True)
|
|
assert f"{alias}-add" in tuple(tool.name for tool in await allowed.list_tools(raise_on_error=True))
|
|
result: Final = await allowed.call_tool(CallToolRequestParams(name=f"{alias}-add", arguments={"a": 2, "b": 5}))
|
|
assert result.is_error is False and result.content[0].text == "7"
|
|
|
|
asyncio.run(exercise())
|