mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-12 03:48:56 +00:00
Some checks are pending
CodSpeed Benchmarks / benchmarks (push) Waiting to run
Deploy main to gateway-dev / dispatch (push) Waiting to run
Publish lint base counts / publish (basedpyright, scripts/type_check_gate.py) (push) Waiting to run
Publish lint base counts / publish (openapi-docs, scripts/openapi_docs_gate.py) (push) Waiting to run
Publish lint base counts / publish (ruff-strict, scripts/ruff_strict_gate.py) (push) Waiting to run
Publish lint base counts / publish (test-quality, scripts/test_quality_gate.py) (push) Waiting to run
Publish lint base counts / publish (type-discipline, scripts/type_discipline_gate.py) (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
LiteLLM Rust / rust-wheel (push) Waiting to run
unit / proxy-server-core (push) Blocked by required conditions
unit / root (push) Blocked by required conditions
unit / proxy-utils (push) Blocked by required conditions
unit / mcp-elicitation (push) Blocked by required conditions
unit / proxy-auth (push) Blocked by required conditions
unit / proxy-endpoints (push) Blocked by required conditions
unit / proxy-extras (push) Blocked by required conditions
unit / proxy-feature-endpoints (push) Blocked by required conditions
unit / proxy-hooks-client (push) Blocked by required conditions
unit / proxy-infra (push) Blocked by required conditions
unit / proxy-infra-root (push) Blocked by required conditions
unit / responses-caching-types (push) Blocked by required conditions
unit / router (push) Blocked by required conditions
unit / rust-bridge-harness (push) Blocked by required conditions
unit / auth-checks (push) Blocked by required conditions
unit / budgets (push) Blocked by required conditions
unit / custom-logging (push) Blocked by required conditions
unit / enterprise-routing (push) Blocked by required conditions
unit / guardrails-hooks (push) Blocked by required conditions
unit / key-generation (push) Blocked by required conditions
unit / logging-misc (push) Blocked by required conditions
unit / assert-shard-coverage (push) Waiting to run
unit / llms-openai-meta (push) Blocked by required conditions
unit / llms-providers (push) Blocked by required conditions
unit / llms-vertex-ai (push) Blocked by required conditions
unit / ui-unit (push) Waiting to run
unit / docs (push) Waiting to run
unit / OpenAI 3.25.0 compatibility (push) Waiting to run
unit / coverage (push) Blocked by required conditions
unit / db-and-spend (push) Blocked by required conditions
unit / endpoints-and-responses (push) Blocked by required conditions
unit / jwt-and-keys (push) Blocked by required conditions
unit / mcp-oauth (push) Blocked by required conditions
unit / proxy-runtime (push) Blocked by required conditions
unit / rust-bridge (push) Waiting to run
unit / enterprise-managed-files (push) Blocked by required conditions
unit / proxy-server (push) Blocked by required conditions
unit / lens-python-310 (push) Blocked by required conditions
unit / caching-local (push) Blocked by required conditions
unit / core-utils (push) Blocked by required conditions
unit / endpoints (push) Blocked by required conditions
unit / enterprise-package (push) Blocked by required conditions
unit / enterprise-repositories-secrets (push) Blocked by required conditions
unit / integrations (push) Blocked by required conditions
unit / llms-anthropic (push) Blocked by required conditions
unit / llms-bedrock (push) Blocked by required conditions
unit / helm (push) Waiting to run
unit / OpenAI 2.20.0 compatibility (push) Waiting to run
unit / OpenAI 3.0.0 compatibility (push) Waiting to run
unit / unit passed (push) Blocked by required conditions
GitHub Actions Security Analysis / zizmor (push) Waiting to run
* test: migrate proxy security, schema migration and MCP legacy tests * test: run the MCP proxy e2e upstream under the SDK1 peer in integration-mcp * test: warm the baseline store before timing the locked projection * test(e2e): hold the stack exclusively while the semantic filter flag is flipped * test(mcp): scope the in-process proxy e2e fixtures to their module The session-scoped autouse fixture deleted DATABASE_URL and overrode LITELLM_MASTER_KEY for the rest of the worker, which broke every later test in the integration mcp suite * test(e2e): assert the semantic filter keeps listed tools instead of a ranking Which Datadog tool an OpenAI embedding ranks first is not something litellm owns, so the test now checks that the kept tools are a non-empty subset of the key's listing within top_k * test(mcp): keep the in-process proxy e2e tests on one worker loadgroup spread the file's tests across workers and interleaved them with other files, so the module-scoped in-process proxy was torn down and restarted between tests and requests timed out * test(mcp): name the upstream requests that carried the wrong token The bare all() assertion hid which request broke the forwarded token, which made an intermittent CircleCI failure undiagnosable --------- Co-authored-by: yuneng <yuneng@berri.ai>
1060 lines
46 KiB
Python
1060 lines
46 KiB
Python
import asyncio
|
|
import importlib
|
|
import json
|
|
import os
|
|
import queue
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import typing
|
|
from collections.abc import Callable, Mapping
|
|
from concurrent.futures import Future
|
|
from contextlib import ExitStack, asynccontextmanager, contextmanager
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Final
|
|
|
|
import httpx
|
|
import pytest
|
|
import uvicorn
|
|
import yaml
|
|
from mcp import ClientSession
|
|
from mcp.client.streamable_http import streamable_http_client
|
|
from mcp.shared._httpx_utils import create_mcp_http_client
|
|
from mcp.types import CallToolResult
|
|
from starlette.requests import Request
|
|
|
|
import litellm
|
|
from litellm.integrations.custom_logger import CustomLogger
|
|
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
|
from litellm.proxy._experimental.mcp_server.tool_search import handle_mcp_proxy_tool
|
|
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, ProxyException, UserAPIKeyAuth
|
|
from litellm.proxy.proxy_server import (
|
|
app as proxy_app,
|
|
)
|
|
from litellm.proxy.proxy_server import (
|
|
cleanup_router_config_variables,
|
|
initialize,
|
|
)
|
|
from tests._master_key import MASTER_KEY
|
|
from tests.integration._support.wire import Reply, Wire, wire_server
|
|
from tests.integration._support.wire import Request as WireRequest
|
|
|
|
CONFIG_TEMPLATE_PATH = Path("tests/integration/mcp/_config_mcp_e2e.yaml")
|
|
MCP_SERVER_SCRIPT = Path("tests/integration/mcp/_math_server.py")
|
|
MCP_PEER_PYTHON = os.environ.get("MCP_TEST_PEER_PYTHON") or sys.executable
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
|
PROXY_START_TIMEOUT = 30
|
|
pytestmark: Final = pytest.mark.xdist_group("proxy_mcp_e2e_in_process")
|
|
|
|
|
|
PROXY_AUTHORIZATION_HEADER = f"Bearer {MASTER_KEY}"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def setup_and_teardown() -> typing.Iterator[None]:
|
|
importlib.reload(litellm)
|
|
loop: Final = asyncio.get_event_loop_policy().new_event_loop()
|
|
asyncio.set_event_loop(loop)
|
|
yield
|
|
asyncio.run(GLOBAL_LOGGING_WORKER.clear_queue())
|
|
loop.close()
|
|
asyncio.set_event_loop(None)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
async def drain_logging_worker() -> typing.AsyncIterator[None]:
|
|
yield
|
|
try:
|
|
await asyncio.wait_for(GLOBAL_LOGGING_WORKER.clear_queue(), timeout=10)
|
|
except asyncio.TimeoutError:
|
|
pass
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("cancel", (False, True))
|
|
@pytest.mark.parametrize("concurrency", (1, 8))
|
|
async def test_schema_validation_stops_expensive_work_and_recovers(cancel: bool, concurrency: int) -> None:
|
|
import psutil
|
|
|
|
from litellm.proxy._experimental.mcp_server.tool_search import _tool_argument_validation_error
|
|
|
|
existing_children: Final = frozenset(child.pid for child in psutil.Process().children())
|
|
warm_count: Final = min(concurrency, 4)
|
|
assert (
|
|
await asyncio.gather(*(_tool_argument_validation_error({"type": "object"}, {}) for _ in range(warm_count)))
|
|
== [None] * warm_count
|
|
)
|
|
started: Final = time.monotonic()
|
|
tasks: Final = tuple(
|
|
asyncio.create_task(
|
|
_tool_argument_validation_error(
|
|
{"type": "object", "properties": {"value": {"type": "string", "pattern": "^(a+)+$"}}},
|
|
{"value": "a" * 80 + "!"},
|
|
)
|
|
)
|
|
for _ in range(concurrency)
|
|
)
|
|
group: Final = asyncio.gather(*tasks, return_exceptions=True)
|
|
try:
|
|
with pytest.raises(TimeoutError):
|
|
await asyncio.wait_for(asyncio.shield(group), timeout=0.2)
|
|
workers: Final = tuple(
|
|
child
|
|
for child in psutil.Process().children()
|
|
if child.pid not in existing_children and "anyio.to_process" in child.cmdline()
|
|
)
|
|
assert 1 <= len(workers) <= 4
|
|
if cancel:
|
|
for task in tasks:
|
|
task.cancel()
|
|
assert all(isinstance(result, asyncio.CancelledError) for result in await group)
|
|
else:
|
|
assert await group == ["Tool argument validation exceeded its time limit"] * concurrency
|
|
assert time.monotonic() - started < 35
|
|
assert all(not worker.is_running() for worker in workers), "Cancelled validation must terminate worker CPU work"
|
|
assert await _tool_argument_validation_error({"type": "object"}, {}) is None
|
|
finally:
|
|
for task in tasks:
|
|
task.cancel()
|
|
await group
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def schema_peer() -> typing.Iterator[Wire]:
|
|
def respond(request: WireRequest) -> Reply:
|
|
if request.method == "GET":
|
|
return Reply(body=b'{"type":"number"}')
|
|
body: Final = json.loads(request.body)
|
|
if "id" not in body:
|
|
return Reply(status=202)
|
|
result: Final[Mapping[str, object]]
|
|
if body["method"] == "initialize":
|
|
result = {
|
|
"protocolVersion": body["params"]["protocolVersion"],
|
|
"capabilities": {"tools": {}},
|
|
"serverInfo": {"name": "schema-peer", "version": "1"},
|
|
}
|
|
elif body["method"] == "tools/list":
|
|
result = {
|
|
"tools": [
|
|
{
|
|
"name": name,
|
|
"description": "Schema validation",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"$defs": {"amount": {"type": "number", "minimum": 0.25, "multipleOf": 0.25}},
|
|
"properties": {
|
|
"value": {"$ref": peer.url + "/ref" if name == "external" else "#/$defs/amount"}
|
|
},
|
|
},
|
|
}
|
|
for name in ("external", "local")
|
|
]
|
|
}
|
|
else:
|
|
assert body["method"] == "tools/call"
|
|
result = {"content": [{"type": "text", "text": "called"}], "isError": False}
|
|
return Reply(body=json.dumps({"jsonrpc": "2.0", "id": body["id"], "result": result}).encode())
|
|
|
|
with wire_server(respond) as peer:
|
|
yield peer
|
|
|
|
|
|
@pytest.mark.parametrize("external", (True, False))
|
|
def test_proxy_schema_validation_resolves_only_local_references(
|
|
proxy_server_url: str, schema_peer: Wire, external: bool
|
|
) -> None:
|
|
with httpx.Client(
|
|
base_url=proxy_server_url,
|
|
headers={
|
|
"Authorization": "Bearer sk-schema",
|
|
"Accept": "application/json, text/event-stream",
|
|
"x-mcp-servers": "schema",
|
|
},
|
|
timeout=30,
|
|
) as client:
|
|
search: Final = _rpc_result(
|
|
client.post(
|
|
"/mcp/proxy",
|
|
json={
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "tools/call",
|
|
"params": {"name": "search_tools", "arguments": {"query": "schema"}},
|
|
},
|
|
)
|
|
)
|
|
name: Final = "schema-external" if external else "schema-local"
|
|
tool_id: Final = next(hit["tool_id"] for hit in json.loads(search["content"][0]["text"]) if hit["name"] == name)
|
|
schema_peer.drain()
|
|
called: Final = _rpc_result(
|
|
client.post(
|
|
"/mcp/proxy",
|
|
json={
|
|
"jsonrpc": "2.0",
|
|
"id": 2,
|
|
"method": "tools/call",
|
|
"params": {
|
|
"name": "call_tool",
|
|
"arguments": {"tool_id": tool_id, "arguments": {"value": 0.75}},
|
|
},
|
|
},
|
|
)
|
|
)
|
|
observed: Final = schema_peer.drain()
|
|
assert not any(request.method == "GET" for request in observed), (
|
|
"Schema validation must not retrieve external references"
|
|
)
|
|
calls: Final = tuple(
|
|
request
|
|
for request in observed
|
|
if request.method == "POST" and json.loads(request.body).get("method") == "tools/call"
|
|
)
|
|
assert len(calls) == (0 if external else 1), "Only valid local schemas may reach upstream execution"
|
|
assert called["isError"] is external
|
|
if not external:
|
|
assert called["content"][0]["text"] == "called"
|
|
|
|
|
|
@pytest.fixture(scope="module", autouse=True)
|
|
def _clear_proxy_database_env() -> typing.Iterator[None]:
|
|
"""Ensure local proxy DB settings don't leak into tests."""
|
|
mp = pytest.MonkeyPatch()
|
|
mp.delenv("DATABASE_URL", raising=False)
|
|
# The FastAPI lifespan event (proxy_startup_event) re-reads master_key from
|
|
# the LITELLM_MASTER_KEY env var, overriding whatever initialize() set from
|
|
# the config file. We must set it here so the lifespan doesn't reset it to None.
|
|
mp.setenv("LITELLM_MASTER_KEY", MASTER_KEY)
|
|
mp.setenv("LITELLM_ENABLE_MCP_STDIO", "true")
|
|
try:
|
|
yield
|
|
finally:
|
|
mp.undo()
|
|
|
|
|
|
async def _initialize_proxy(config_path: str) -> None:
|
|
from litellm.proxy._experimental.mcp_server.catalog import CatalogSnapshots
|
|
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
|
|
cleanup_router_config_variables()
|
|
global_mcp_server_manager.catalog = CatalogSnapshots(global_mcp_server_manager)
|
|
await initialize(config=config_path, debug=True)
|
|
for server_id, upstream in tuple(global_mcp_server_manager.registry.items()):
|
|
if upstream.server_name != "math_restricted":
|
|
continue
|
|
global_mcp_server_manager.registry[server_id] = upstream.model_copy(
|
|
update={"tool_name_to_display_name": {"add": "Add Numbers"}}
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProxyRig:
|
|
url: str
|
|
config_path: str
|
|
loop: asyncio.AbstractEventLoop
|
|
|
|
|
|
def _close_startup_loop(loop: asyncio.AbstractEventLoop) -> None:
|
|
if not loop.is_running() and not loop.is_closed():
|
|
loop.close()
|
|
|
|
|
|
def _abort_proxy_startup(server: uvicorn.Server, thread: threading.Thread, loop: asyncio.AbstractEventLoop) -> None:
|
|
server.should_exit = True
|
|
if not thread.is_alive():
|
|
return
|
|
|
|
def cancel_owned_tasks() -> None:
|
|
for task in asyncio.all_tasks(loop):
|
|
task.cancel()
|
|
|
|
loop.call_soon_threadsafe(cancel_owned_tasks)
|
|
thread.join(timeout=10)
|
|
assert not thread.is_alive(), "Proxy startup failure left its thread running"
|
|
|
|
|
|
def _start_proxy_server(
|
|
config_path: str,
|
|
*,
|
|
socket_factory: Callable[[], socket.socket] = socket.socket,
|
|
) -> tuple[ProxyRig, uvicorn.Server, threading.Thread, socket.socket]:
|
|
with ExitStack() as startup:
|
|
sock: Final = socket_factory()
|
|
startup.callback(sock.close)
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
sock.bind(("127.0.0.1", 0))
|
|
host, port = sock.getsockname()
|
|
|
|
config = uvicorn.Config(proxy_app, host=host, port=port, log_level="warning", lifespan="off")
|
|
server = uvicorn.Server(config)
|
|
|
|
loop: Final = asyncio.new_event_loop()
|
|
startup.callback(_close_startup_loop, loop)
|
|
failure: Final[Future[BaseException | None]] = Future()
|
|
|
|
async def _serve() -> None:
|
|
from litellm.proxy._experimental.mcp_server import server as mcp_server
|
|
|
|
await _initialize_proxy(config_path)
|
|
async with proxy_app.router.lifespan_context(proxy_app), mcp_server.lifespan(proxy_app):
|
|
await server.serve(sockets=[sock])
|
|
|
|
def _run() -> None:
|
|
try:
|
|
with asyncio.Runner(loop_factory=lambda: loop) as runner:
|
|
runner.run(_serve())
|
|
except BaseException as error:
|
|
failure.set_result(error)
|
|
else:
|
|
failure.set_result(None)
|
|
|
|
thread: Final = threading.Thread(target=_run, daemon=True)
|
|
startup.callback(_abort_proxy_startup, server, thread, loop)
|
|
thread.start()
|
|
|
|
start_time = time.time()
|
|
while not server.started:
|
|
if not thread.is_alive():
|
|
raise RuntimeError("Proxy server failed to start") from failure.result()
|
|
if time.time() - start_time > PROXY_START_TIMEOUT:
|
|
raise TimeoutError("Proxy server did not start in time")
|
|
time.sleep(0.05)
|
|
|
|
startup.pop_all()
|
|
return ProxyRig(f"http://{host}:{port}", config_path, loop), server, thread, sock
|
|
|
|
|
|
def _check_failed_startup(config: str) -> None:
|
|
threads: Final = frozenset(threading.enumerate())
|
|
with socket.socket() as owned:
|
|
with pytest.raises(RuntimeError, match="Proxy server failed to start"):
|
|
_start_proxy_server(config, socket_factory=lambda: owned)
|
|
assert owned.fileno() == -1
|
|
assert frozenset(threading.enumerate()) == threads
|
|
|
|
|
|
def test_proxy_startup_failure_closes_acquired_resources(tmp_path: Path) -> None:
|
|
config: Final = tmp_path / "invalid-config.yaml"
|
|
config.write_text("model_list: [\n")
|
|
result: Final = subprocess.run(
|
|
(
|
|
sys.executable,
|
|
"-P",
|
|
"-c",
|
|
"import sys; from tests.integration.mcp.test_proxy_mcp_e2e import _check_failed_startup; "
|
|
"_check_failed_startup(sys.argv[1])",
|
|
str(config),
|
|
),
|
|
env={**os.environ, "LITELLM_MODE": "PRODUCTION", "PYTHON_DOTENV_DISABLED": "1"},
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
check=False,
|
|
)
|
|
assert result.returncode == 0, result.stderr
|
|
|
|
|
|
@contextmanager
|
|
def _math_http_server(offset: int) -> typing.Iterator[str]:
|
|
host = "127.0.0.1"
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
sock.bind((host, 0))
|
|
_, port = sock.getsockname()
|
|
|
|
with tempfile.TemporaryFile() as server_log:
|
|
process = subprocess.Popen(
|
|
[MCP_PEER_PYTHON, str(MCP_SERVER_SCRIPT), "--transport", "http", "--host", host, "--port", str(port)],
|
|
cwd=str(PROJECT_ROOT),
|
|
stdout=server_log,
|
|
stderr=subprocess.STDOUT,
|
|
env={**os.environ, "MCP_ADD_OFFSET": str(offset)},
|
|
)
|
|
try:
|
|
start_time = time.monotonic()
|
|
while True:
|
|
if process.poll() is not None:
|
|
server_log.seek(0)
|
|
raise RuntimeError(f"MCP upstream exited early: {server_log.read().decode()}")
|
|
try:
|
|
with socket.create_connection((host, port), timeout=0.1):
|
|
break
|
|
except OSError:
|
|
if time.monotonic() - start_time > PROXY_START_TIMEOUT:
|
|
raise TimeoutError("Streamable HTTP MCP server did not start in time")
|
|
time.sleep(0.05)
|
|
yield f"http://{host}:{port}"
|
|
finally:
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.wait(timeout=5)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def math_streamable_http_server() -> typing.Iterator[str]:
|
|
with _math_http_server(100) as url:
|
|
yield url
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def math_restricted_server() -> typing.Iterator[str]:
|
|
with _math_http_server(200) as url:
|
|
yield url
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def _proxy_server(
|
|
tmp_path_factory: pytest.TempPathFactory,
|
|
math_streamable_http_server: str,
|
|
math_restricted_server: str,
|
|
schema_peer: Wire,
|
|
):
|
|
config_dir = tmp_path_factory.mktemp("mcp_e2e")
|
|
config_path = config_dir / "config.yaml"
|
|
config = yaml.safe_load(CONFIG_TEMPLATE_PATH.read_text())
|
|
config["mcp_servers"]["schema"] = {"transport": "http", "url": schema_peer.url + "/mcp"}
|
|
config["mcp_servers"]["math_stdio"]["command"] = MCP_PEER_PYTHON
|
|
config["mcp_servers"]["math_streamable_http"]["url"] = f"{math_streamable_http_server}/mcp"
|
|
config["mcp_servers"]["math_restricted"]["url"] = f"{math_restricted_server}/mcp"
|
|
config["general_settings"]["custom_auth"] = f"{__name__}.authorize_proxy_key"
|
|
config["litellm_settings"]["callbacks"] = [f"{__name__}.proxy_call_recorder"]
|
|
config["mcp_servers"]["math_restricted"]["mcp_info"] = {"mcp_server_cost_info": {"default_cost_per_query": 0.25}}
|
|
config_path.write_text(yaml.safe_dump(config))
|
|
|
|
rig, server, thread, sock = _start_proxy_server(str(config_path))
|
|
|
|
try:
|
|
yield rig
|
|
finally:
|
|
server.should_exit = True
|
|
thread.join(timeout=10)
|
|
sock.close()
|
|
assert not thread.is_alive(), "Proxy did not shut down"
|
|
|
|
|
|
@pytest.fixture
|
|
def proxy_server_url(_proxy_server: ProxyRig, setup_and_teardown: None) -> str:
|
|
asyncio.run_coroutine_threadsafe(_initialize_proxy(_proxy_server.config_path), _proxy_server.loop).result(
|
|
timeout=30
|
|
)
|
|
return _proxy_server.url
|
|
|
|
|
|
@asynccontextmanager
|
|
async def _http_streams(url: str, headers: dict[str, str]):
|
|
async with create_mcp_http_client(headers=headers) as http_client:
|
|
async with streamable_http_client(url, http_client=http_client) as streams:
|
|
yield streams
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unchanged_sdk1_langchain_peer_can_list_and_call(proxy_server_url: str) -> None:
|
|
script = """
|
|
import asyncio, json, os, sys
|
|
from mcp import ClientSession
|
|
from mcp.client.streamable_http import streamablehttp_client
|
|
from langchain_mcp_adapters.tools import load_mcp_tools
|
|
|
|
async def main():
|
|
async with streamablehttp_client(
|
|
sys.argv[1] + "/mcp",
|
|
headers={"Authorization": "Bearer " + os.environ["LITELLM_MASTER_KEY"]},
|
|
) as (read, write, _):
|
|
async with ClientSession(read, write) as session:
|
|
await session.initialize()
|
|
tools = await load_mcp_tools(session)
|
|
results = {}
|
|
for name in ('math_stdio-add', 'math_streamable_http-add'):
|
|
tool = next(tool for tool in tools if tool.name == name)
|
|
results[name] = await tool.ainvoke({'a': 3, 'b': 4})
|
|
print(json.dumps(results))
|
|
asyncio.run(main())
|
|
"""
|
|
completed = await asyncio.to_thread(
|
|
subprocess.run,
|
|
[MCP_PEER_PYTHON, "-c", script, proxy_server_url],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
check=True,
|
|
)
|
|
results = json.loads(completed.stdout)
|
|
assert [(item["type"], item["text"]) for item in results["math_stdio-add"]] == [("text", "7")]
|
|
assert [(item["type"], item["text"]) for item in results["math_streamable_http-add"]] == [("text", "107")]
|
|
|
|
|
|
@pytest.mark.parametrize("requested", ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25", "2026-07-28"])
|
|
def test_initialize_keeps_legacy_negotiation(proxy_server_url: str, requested: str) -> None:
|
|
response = httpx.post(
|
|
proxy_server_url + "/mcp",
|
|
headers={"Authorization": PROXY_AUTHORIZATION_HEADER, "Accept": "application/json, text/event-stream"},
|
|
json={
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "initialize",
|
|
"params": {
|
|
"protocolVersion": requested,
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "legacy-test", "version": "1"},
|
|
},
|
|
},
|
|
timeout=10,
|
|
)
|
|
assert response.status_code == 200
|
|
result = _rpc_result(response)
|
|
assert result["protocolVersion"] == ("2025-11-25" if requested == "2026-07-28" else requested)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_legacy_prompts_and_resources_round_trip(proxy_server_url: str) -> None:
|
|
async with _http_streams(
|
|
proxy_server_url + "/mcp",
|
|
{"Authorization": PROXY_AUTHORIZATION_HEADER, "x-mcp-servers": "math_streamable_http"},
|
|
) as (read, write):
|
|
async with ClientSession(read, write) as session:
|
|
await session.initialize()
|
|
prompts = await session.list_prompts()
|
|
greeting = next(prompt for prompt in prompts.prompts if prompt.name.endswith("greeting"))
|
|
prompt = await session.get_prompt(greeting.name, {"name": "Ada"})
|
|
assert prompt.messages[0].content.text == "Hello, Ada"
|
|
resources = await session.list_resources()
|
|
status = next(resource for resource in resources.resources if resource.name.endswith("status"))
|
|
contents = await session.read_resource(status.uri)
|
|
assert contents.contents[0].text == "ready"
|
|
templates = await session.list_resource_templates()
|
|
greeting_template = next(
|
|
template for template in templates.resource_templates if "greeting" in template.name
|
|
)
|
|
contents = await session.read_resource(greeting_template.uri_template.replace("{name}", "Ada"))
|
|
assert contents.contents[0].text == "Hello, Ada"
|
|
|
|
|
|
class TestProxyMcpSimpleConnections:
|
|
@pytest.mark.asyncio
|
|
async def test_proxy_mcp_stdio_roundtrip(self, proxy_server_url: str) -> None:
|
|
async with asyncio.timeout(20):
|
|
async with _http_streams(
|
|
url=f"{proxy_server_url}/mcp",
|
|
headers={
|
|
"Authorization": PROXY_AUTHORIZATION_HEADER,
|
|
"x-mcp-servers": "math_stdio",
|
|
},
|
|
) as (read, write):
|
|
async with ClientSession(read, write) as session:
|
|
await session.initialize()
|
|
tools_result = await session.list_tools()
|
|
assert any(tool.name.endswith("add") for tool in tools_result.tools)
|
|
|
|
result = await session.call_tool("add", arguments={"a": 3, "b": 4})
|
|
assert result.content
|
|
first_content = result.content[0]
|
|
text = getattr(first_content, "text", None)
|
|
assert text == "7"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_proxy_mcp_streamable_http_roundtrip(self, proxy_server_url: str) -> None:
|
|
async with asyncio.timeout(20):
|
|
async with _http_streams(
|
|
url=f"{proxy_server_url}/mcp",
|
|
headers={
|
|
"Authorization": PROXY_AUTHORIZATION_HEADER,
|
|
"x-mcp-servers": "math_streamable_http",
|
|
},
|
|
) as (read, write):
|
|
async with ClientSession(read, write) as session:
|
|
await session.initialize()
|
|
tools_result = await session.list_tools()
|
|
assert any(tool.name.endswith("add") for tool in tools_result.tools)
|
|
|
|
result = await session.call_tool("add", arguments={"a": 5, "b": 6})
|
|
assert result.content
|
|
first_content = result.content[0]
|
|
text = getattr(first_content, "text", None)
|
|
assert text == "111"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_proxy_mcp_lists_all_servers_without_header(self, proxy_server_url: str) -> None:
|
|
async with asyncio.timeout(20):
|
|
async with _http_streams(
|
|
url=f"{proxy_server_url}/mcp",
|
|
headers={"Authorization": PROXY_AUTHORIZATION_HEADER},
|
|
) as (read, write):
|
|
async with ClientSession(read, write) as session:
|
|
await session.initialize()
|
|
tools_result = await session.list_tools()
|
|
tool_names = {tool.name for tool in tools_result.tools}
|
|
expected_tool_names = {
|
|
"math_stdio-add",
|
|
"math_stdio-multiply",
|
|
"math_streamable_http-add",
|
|
"math_streamable_http-multiply",
|
|
}
|
|
assert expected_tool_names <= tool_names
|
|
|
|
async def _call_and_get_text(tool_name: str, *, a: int, b: int) -> str | None:
|
|
result = await session.call_tool(tool_name, arguments={"a": a, "b": b})
|
|
assert result.content
|
|
first_content = result.content[0]
|
|
return getattr(first_content, "text", None)
|
|
|
|
stdio_result = await _call_and_get_text("math_stdio-add", a=2, b=3)
|
|
streamable_result = await _call_and_get_text("math_streamable_http-add", a=4, b=5)
|
|
assert stdio_result == "5"
|
|
assert streamable_result == "109"
|
|
|
|
|
|
class TestProxyMcpStatelessBehavior:
|
|
"""
|
|
Verify that the LiteLLM MCP proxy operates in stateless mode.
|
|
|
|
When StreamableHTTPSessionManager is configured with stateless=True,
|
|
independent clients must be able to connect, list tools, and call tools
|
|
without sharing or inheriting session state from other clients.
|
|
|
|
With stateless=False this fails because the server tracks sessions and
|
|
expects clients to supply an mcp-session-id header obtained from a
|
|
prior handshake — breaking clients that don't manage session IDs.
|
|
|
|
Regression test for https://github.com/BerriAI/litellm/issues/20242
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_independent_clients_no_shared_session(self, proxy_server_url: str) -> None:
|
|
"""Two independent clients connect and operate without sharing session state."""
|
|
async with asyncio.timeout(30):
|
|
# --- Client A: connect, initialize, call tool ---
|
|
async with _http_streams(
|
|
url=f"{proxy_server_url}/mcp",
|
|
headers={
|
|
"Authorization": PROXY_AUTHORIZATION_HEADER,
|
|
"x-mcp-servers": "math_stdio",
|
|
},
|
|
) as (read_a, write_a):
|
|
async with ClientSession(read_a, write_a) as session_a:
|
|
await session_a.initialize()
|
|
result_a = await session_a.call_tool("math_stdio-add", arguments={"a": 10, "b": 20})
|
|
assert result_a.content
|
|
text_a = getattr(result_a.content[0], "text", None)
|
|
assert text_a == "30"
|
|
|
|
# Allow proxy and MCP SDK to fully clean up the first connection before
|
|
# opening the second. Without this, the SDK's TaskGroup can raise
|
|
# ExceptionGroup when the server closes the connection (see MCP SDK #915).
|
|
await asyncio.sleep(0.5)
|
|
|
|
# --- Client B: completely independent connection ---
|
|
async with _http_streams(
|
|
url=f"{proxy_server_url}/mcp",
|
|
headers={
|
|
"Authorization": PROXY_AUTHORIZATION_HEADER,
|
|
"x-mcp-servers": "math_stdio",
|
|
},
|
|
) as (read_b, write_b):
|
|
async with ClientSession(read_b, write_b) as session_b:
|
|
await session_b.initialize()
|
|
tools = await session_b.list_tools()
|
|
assert any(t.name.endswith("add") for t in tools.tools)
|
|
result_b = await session_b.call_tool("math_stdio-add", arguments={"a": 100, "b": 200})
|
|
assert result_b.content
|
|
text_b = getattr(result_b.content[0], "text", None)
|
|
assert text_b == "300"
|
|
|
|
|
|
PROXY_MODE_TOOLS = frozenset({"search_tools", "get_tool_schema", "call_tool"})
|
|
|
|
|
|
def _payload(result: typing.Any) -> typing.Any:
|
|
assert result.content, f"empty tool result: {result}"
|
|
return json.loads(result.content[0].text)
|
|
|
|
|
|
def _proxy_session(proxy_server_url: str, **extra_headers: str):
|
|
return _http_streams(
|
|
url=f"{proxy_server_url}/mcp/proxy",
|
|
headers={"Authorization": PROXY_AUTHORIZATION_HEADER, **extra_headers},
|
|
)
|
|
|
|
|
|
class TestProxyMcpSchemaDiscoveryMode:
|
|
"""Drive /mcp/proxy over the real streamable-HTTP transport with the MCP SDK client:
|
|
the fixed three-tool surface, opaque-id discovery, schema-validated execution against
|
|
two upstreams that expose the same tool name, and the operations the surface refuses."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_initialize_and_list_expose_only_discovery_tools(self, proxy_server_url: str) -> None:
|
|
async with asyncio.timeout(20):
|
|
async with _proxy_session(proxy_server_url) as (read, write):
|
|
async with ClientSession(read, write) as session:
|
|
init = await session.initialize()
|
|
assert init.capabilities.tools is not None
|
|
assert init.capabilities.prompts is None
|
|
assert init.capabilities.resources is None
|
|
|
|
listed = await session.list_tools()
|
|
assert {tool.name for tool in listed.tools} == PROXY_MODE_TOOLS
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_schema_and_call_round_trip_keeps_server_identity(self, proxy_server_url: str) -> None:
|
|
async with asyncio.timeout(30):
|
|
async with _proxy_session(proxy_server_url) as (read, write):
|
|
async with ClientSession(read, write) as session:
|
|
await session.initialize()
|
|
|
|
hits = _payload(await session.call_tool("search_tools", arguments={"query": "add"}))
|
|
by_name = {hit["name"]: hit for hit in hits}
|
|
assert {"math_stdio-add", "math_streamable_http-add"} <= set(by_name)
|
|
assert all("inputSchema" not in hit for hit in hits)
|
|
assert by_name["math_stdio-add"]["tool_id"] != by_name["math_streamable_http-add"]["tool_id"]
|
|
|
|
schema = _payload(
|
|
await session.call_tool(
|
|
"get_tool_schema", arguments={"tool_id": by_name["math_stdio-add"]["tool_id"]}
|
|
)
|
|
)
|
|
assert schema["name"] == "math_stdio-add"
|
|
assert set(schema["inputSchema"]["required"]) == {"a", "b"}
|
|
assert schema["outputSchema"]["properties"]["result"]["type"] == "integer"
|
|
|
|
stdio = await session.call_tool(
|
|
"call_tool",
|
|
arguments={"tool_id": by_name["math_stdio-add"]["tool_id"], "arguments": {"a": 3, "b": 4}},
|
|
)
|
|
http = await session.call_tool(
|
|
"call_tool",
|
|
arguments={
|
|
"tool_id": by_name["math_streamable_http-add"]["tool_id"],
|
|
"arguments": {"a": 5, "b": 6},
|
|
},
|
|
)
|
|
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:
|
|
async with asyncio.timeout(20):
|
|
async with _proxy_session(proxy_server_url, **{"x-mcp-servers": "math_streamable_http"}) as (
|
|
read,
|
|
write,
|
|
):
|
|
async with ClientSession(read, write) as session:
|
|
await session.initialize()
|
|
hits = _payload(await session.call_tool("search_tools", arguments={"query": "add"}))
|
|
assert {hit["name"] for hit in hits} == {"math_streamable_http-add"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rejections_never_reach_upstream(self, proxy_server_url: str) -> None:
|
|
from mcp.shared.exceptions import MCPError
|
|
from mcp.types import METHOD_NOT_FOUND
|
|
|
|
async with asyncio.timeout(30):
|
|
async with _proxy_session(proxy_server_url) as (read, write):
|
|
async with ClientSession(read, write) as session:
|
|
await session.initialize()
|
|
hits = _payload(await session.call_tool("search_tools", arguments={"query": "add"}))
|
|
tool_id = next(hit["tool_id"] for hit in hits if hit["name"] == "math_stdio-add")
|
|
|
|
bad_args = await session.call_tool(
|
|
"call_tool", arguments={"tool_id": tool_id, "arguments": {"a": "three", "b": 4}}
|
|
)
|
|
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.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.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.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:
|
|
await operation()
|
|
assert refused.value.error.code == METHOD_NOT_FOUND
|
|
|
|
|
|
async def authorize_proxy_key(request: Request, api_key: str) -> UserAPIKeyAuth:
|
|
permissions = {
|
|
"sk-schema": LiteLLM_ObjectPermissionTable(object_permission_id="schema", mcp_servers=["schema"]),
|
|
MASTER_KEY: LiteLLM_ObjectPermissionTable(object_permission_id="open", mcp_servers=["math_stdio"]),
|
|
"sk-restricted": LiteLLM_ObjectPermissionTable(
|
|
object_permission_id="restricted", mcp_servers=["math_restricted"]
|
|
),
|
|
"sk-none": LiteLLM_ObjectPermissionTable(object_permission_id="none", mcp_servers=["no-mcp-servers"]),
|
|
"sk-add-only": LiteLLM_ObjectPermissionTable(
|
|
object_permission_id="add-only", mcp_servers=["math_stdio"], mcp_tool_permissions={"math_stdio": ["add"]}
|
|
),
|
|
}
|
|
permission = permissions.get(api_key)
|
|
if permission is None:
|
|
raise ProxyException(message="Unknown test key", type="authentication_error", param=None, code=401)
|
|
return UserAPIKeyAuth(api_key=api_key, user_id=api_key, object_permission=permission)
|
|
|
|
|
|
class ProxyCallRecorder(CustomLogger):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.events: queue.Queue[str] = queue.Queue()
|
|
self.failures: queue.Queue[str] = queue.Queue()
|
|
|
|
async def async_log_success_event(
|
|
self, kwargs: dict[str, object], response_obj: object, start_time: datetime, end_time: datetime
|
|
) -> None:
|
|
payload = kwargs.get("standard_logging_object")
|
|
if isinstance(payload, dict) and payload.get("call_type") == "call_mcp_tool":
|
|
self.events.put(json.dumps(payload, default=str))
|
|
|
|
async def async_log_failure_event(
|
|
self, kwargs: dict[str, object], response_obj: object, start_time: datetime, end_time: datetime
|
|
) -> None:
|
|
payload = kwargs.get("standard_logging_object")
|
|
if isinstance(payload, dict) and payload.get("call_type") == "call_mcp_tool":
|
|
self.failures.put(json.dumps(payload, default=str))
|
|
|
|
|
|
proxy_call_recorder = ProxyCallRecorder()
|
|
|
|
|
|
@asynccontextmanager
|
|
async def _scoped_session(url: str, key: str = MASTER_KEY, **headers: str) -> typing.AsyncIterator[ClientSession]:
|
|
async with asyncio.timeout(30):
|
|
async with _proxy_session(url, Authorization=f"Bearer {key}", **headers) as (read, write):
|
|
async with ClientSession(read, write) as session:
|
|
await session.initialize()
|
|
yield session
|
|
|
|
|
|
async def _search(session: ClientSession, query: str) -> dict[str, str]:
|
|
result = await session.call_tool("search_tools", arguments={"query": query})
|
|
assert result.is_error is False, result
|
|
return {hit["name"]: hit["tool_id"] for hit in _payload(result)}
|
|
|
|
|
|
async def _call(session: ClientSession, tool_id: str, a: int = 3, b: int = 4) -> CallToolResult:
|
|
return await session.call_tool("call_tool", arguments={"tool_id": tool_id, "arguments": {"a": a, "b": b}})
|
|
|
|
|
|
async def _raw_rpc(
|
|
proxy_server_url: str, key: str | None, method: str, params: dict[str, object], **headers: str
|
|
) -> httpx.Response:
|
|
async with httpx.AsyncClient() as client:
|
|
return await client.post(
|
|
f"{proxy_server_url}/mcp/proxy",
|
|
headers={
|
|
"Accept": "application/json, text/event-stream",
|
|
**({"Authorization": f"Bearer {key}"} if key else {}),
|
|
**headers,
|
|
},
|
|
json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params},
|
|
)
|
|
|
|
|
|
async def _raw_initialize(proxy_server_url: str, key: str | None) -> httpx.Response:
|
|
return await _raw_rpc(
|
|
proxy_server_url,
|
|
key,
|
|
"initialize",
|
|
{"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "auth-test", "version": "1"}},
|
|
)
|
|
|
|
|
|
def _rpc_result(response: httpx.Response) -> dict[str, typing.Any]:
|
|
if response.headers["content-type"].startswith("text/event-stream"):
|
|
data_line = next(line for line in response.text.splitlines() if line.startswith("data:"))
|
|
return json.loads(data_line.removeprefix("data:"))["result"]
|
|
return response.json()["result"]
|
|
|
|
|
|
def _assert_unauthorized(result: CallToolResult) -> None:
|
|
assert result.is_error is True
|
|
assert result.content[0].text == "Unknown or unauthorized tool_id"
|
|
|
|
|
|
class TestProxyMcpAuthorizationScope:
|
|
@pytest.mark.asyncio
|
|
async def test_server_grant_bounds_search_and_blocks_foreign_ids(self, proxy_server_url: str) -> None:
|
|
async with _scoped_session(proxy_server_url, "sk-restricted") as granted:
|
|
restricted_id = (await _search(granted, "add"))["math_restricted-add"]
|
|
assert (await _call(granted, restricted_id)).content[0].text == "207"
|
|
async with _scoped_session(proxy_server_url) as ungranted:
|
|
assert set(await _search(ungranted, "add")) == {"math_stdio-add", "math_streamable_http-add"}
|
|
_assert_unauthorized(await ungranted.call_tool("get_tool_schema", {"tool_id": restricted_id}))
|
|
_assert_unauthorized(await _call(ungranted, restricted_id))
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_mcp_servers_sentinel_rejects_initialize_and_hides_every_tool(self, proxy_server_url: str) -> None:
|
|
async with _scoped_session(proxy_server_url) as granted:
|
|
tool_id = (await _search(granted, "add"))["math_stdio-add"]
|
|
response = await _raw_initialize(proxy_server_url, "sk-none")
|
|
assert response.status_code == 403, response.text
|
|
assert "no MCP servers granted" in response.json()["detail"]["error"]
|
|
|
|
async def raw_call(name: str, arguments: dict[str, object]) -> dict[str, typing.Any]:
|
|
call = await _raw_rpc(proxy_server_url, "sk-none", "tools/call", {"name": name, "arguments": arguments})
|
|
assert call.status_code == 200, call.text
|
|
return _rpc_result(call)
|
|
|
|
listed = await _raw_rpc(proxy_server_url, "sk-none", "tools/list", {})
|
|
assert listed.status_code == 200, listed.text
|
|
assert {tool["name"] for tool in _rpc_result(listed)["tools"]} == {
|
|
"search_tools",
|
|
"get_tool_schema",
|
|
"call_tool",
|
|
}
|
|
search = await raw_call("search_tools", {"query": "add"})
|
|
assert search["isError"] is False, search
|
|
assert json.loads(search["content"][0]["text"]) == []
|
|
for name, arguments in (
|
|
("get_tool_schema", {"tool_id": tool_id}),
|
|
("call_tool", {"tool_id": tool_id, "arguments": {"a": 3, "b": 4}}),
|
|
):
|
|
denied = await raw_call(name, arguments)
|
|
assert denied["isError"] is True, denied
|
|
assert denied["content"][0]["text"] == "Unknown or unauthorized tool_id"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_grant_hides_ungranted_tools_and_blocks_their_ids(self, proxy_server_url: str) -> None:
|
|
async with _scoped_session(proxy_server_url) as granted:
|
|
multiply_id = (await _search(granted, "multiply"))["math_stdio-multiply"]
|
|
async with _scoped_session(proxy_server_url, "sk-add-only", **{"x-mcp-servers": "math_stdio"}) as session:
|
|
ids = await _search(session, "add multiply request_headers")
|
|
assert set(ids) == {"math_stdio-add"}
|
|
assert (await _call(session, ids["math_stdio-add"])).content[0].text == "7"
|
|
_assert_unauthorized(await session.call_tool("get_tool_schema", {"tool_id": multiply_id}))
|
|
_assert_unauthorized(await _call(session, multiply_id))
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_same_named_tools_keep_distinct_ids_and_reach_their_own_upstream(self, proxy_server_url: str) -> None:
|
|
async with _scoped_session(proxy_server_url, "sk-restricted") as session:
|
|
ids = await _search(session, "add")
|
|
assert set(ids) == {"math_stdio-add", "math_streamable_http-add", "math_restricted-add"}
|
|
assert len(set(ids.values())) == 3
|
|
assert all(len(tool_id) == 32 for tool_id in ids.values())
|
|
for name, expected in (
|
|
("math_stdio-add", "7"),
|
|
("math_streamable_http-add", "107"),
|
|
("math_restricted-add", "207"),
|
|
):
|
|
schema = _payload(await session.call_tool("get_tool_schema", {"tool_id": ids[name]}))
|
|
assert schema["name"] == name
|
|
assert schema["tool_id"] == ids[name]
|
|
result = await _call(session, ids[name])
|
|
assert result.is_error is False
|
|
assert result.content[0].text == expected
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_server_scope_header_narrows_grants_and_blocks_out_of_scope_ids(self, proxy_server_url: str) -> None:
|
|
async with _scoped_session(proxy_server_url, "sk-restricted") as unscoped:
|
|
other_id = (await _search(unscoped, "add"))["math_stdio-add"]
|
|
async with _scoped_session(
|
|
proxy_server_url, "sk-restricted", **{"x-mcp-servers": "math_restricted"}
|
|
) as session:
|
|
ids = await _search(session, "add")
|
|
assert set(ids) == {"math_restricted-add"}
|
|
_assert_unauthorized(await session.call_tool("get_tool_schema", {"tool_id": other_id}))
|
|
_assert_unauthorized(await _call(session, other_id))
|
|
assert (await _call(session, ids["math_restricted-add"])).content[0].text == "207"
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("key", [None, "sk-invalid"])
|
|
async def test_missing_or_invalid_key_cannot_initialize(self, proxy_server_url: str, key: str | None) -> None:
|
|
response = await _raw_initialize(proxy_server_url, key)
|
|
assert response.status_code == 401, response.text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_server_headers_are_forwarded_only_to_the_named_upstream(self, proxy_server_url: str) -> None:
|
|
for tag in ("first-request", "second-request"):
|
|
async with _scoped_session(
|
|
proxy_server_url,
|
|
"sk-restricted",
|
|
**{
|
|
"x-mcp-math_restricted-authorization": f"Bearer {tag}",
|
|
"x-mcp-math_restricted-x-request-tag": tag,
|
|
},
|
|
) as session:
|
|
ids = await _search(session, "request_headers")
|
|
for name, expected in (
|
|
("math_restricted", {"authorization": f"Bearer {tag}", "x-request-tag": tag}),
|
|
("math_streamable_http", {"authorization": "", "x-request-tag": ""}),
|
|
):
|
|
result = await session.call_tool(
|
|
"call_tool", {"tool_id": ids[f"{name}-request_headers"], "arguments": {}}
|
|
)
|
|
assert result.is_error is False
|
|
assert _payload(result) == expected
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_proxy_call_emits_spend_log(self, proxy_server_url: str) -> None:
|
|
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.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))
|
|
if payload.get("metadata", {}).get("mcp_tool_call_metadata", {}).get("arguments") == {
|
|
"a": 123,
|
|
"b": 456,
|
|
}:
|
|
break
|
|
assert payload["call_type"] == "call_mcp_tool"
|
|
assert payload["response_cost"] == 0.25
|
|
assert payload["status"] == "success"
|
|
assert payload["metadata"]["mcp_tool_call_metadata"]["mcp_server_name"] == "math_restricted"
|
|
assert payload["metadata"]["mcp_tool_call_metadata"]["name"] == "add"
|
|
assert payload["metadata"]["mcp_tool_call_metadata"]["namespaced_tool_name"] == "math_restricted/add"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_proxy_scope_exception_returns_iserror_and_emits_failure_log(self, proxy_server_url: str) -> None:
|
|
response = await _raw_rpc(
|
|
proxy_server_url,
|
|
"sk-none",
|
|
"tools/call",
|
|
{"name": "call_tool", "arguments": {"tool_id": "denied-scope", "arguments": {}}},
|
|
**{"x-mcp-servers": "math_restricted", "x-litellm-call-id": "proxy-scope-denial"},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
result = _rpc_result(response)
|
|
assert result["isError"] is True
|
|
assert result["content"][0]["text"] == (
|
|
"Error: The key is not allowed to access the requested MCP servers: math_restricted"
|
|
)
|
|
async with asyncio.timeout(10):
|
|
while True:
|
|
payload = json.loads(await asyncio.to_thread(proxy_call_recorder.failures.get, True, 5))
|
|
if payload["id"] == "proxy-scope-denial":
|
|
break
|
|
assert payload["call_type"] == "call_mcp_tool"
|
|
assert payload["status"] == "failure"
|
|
assert payload["response_cost"] == 0
|
|
assert "math_restricted" in payload["error_str"]
|
|
|
|
@pytest.mark.parametrize("arguments", ["wrong", False, None, [], 0])
|
|
def test_handler_rejects_non_object_arguments(
|
|
self, proxy_server_url: str, _proxy_server: ProxyRig, arguments: object
|
|
) -> None:
|
|
async def check() -> None:
|
|
auth = UserAPIKeyAuth(
|
|
object_permission=LiteLLM_ObjectPermissionTable(
|
|
object_permission_id="validation", mcp_servers=["math_stdio"]
|
|
)
|
|
)
|
|
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.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)
|