mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-09 08:18:12 +00:00
5108 lines
194 KiB
Python
5108 lines
194 KiB
Python
import asyncio
|
|
import inspect
|
|
import json
|
|
import sys
|
|
from datetime import datetime
|
|
from typing import Any, Dict, Final, Optional
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from litellm.proxy.mcp_server import operations as mcp_operations
|
|
|
|
if sys.version_info < (3, 11): # BaseExceptionGroup is a builtin only from 3.11
|
|
from exceptiongroup import BaseExceptionGroup
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from mcp.types import CallToolResult, TextContent, Tool
|
|
from starlette.requests import Request
|
|
|
|
from litellm.constants import MCP_TOOL_LISTING_TIMEOUT
|
|
from litellm.proxy._types import (
|
|
NewMCPServerRequest,
|
|
UpdateMCPServerRequest,
|
|
UserAPIKeyAuth,
|
|
)
|
|
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
|
from litellm.proxy.mcp_server import rest_endpoints
|
|
from litellm.proxy.mcp_server.auth import (
|
|
user_api_key_auth_mcp as auth_mcp,
|
|
)
|
|
from litellm.types.mcp import MCPAuth, MCPTransport, MCPUpstreamProtocol
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
_OK_TOOL_RESULT: Final = CallToolResult(content=[TextContent(type="text", text='{"result": "ok"}')], is_error=False)
|
|
|
|
|
|
def _rendered_log_message(call):
|
|
message = str(call.args[0])
|
|
values = call.args[1:]
|
|
return message % values if values else message
|
|
|
|
|
|
def _build_request(
|
|
headers: Optional[Dict[str, str]] = None,
|
|
*,
|
|
path: str = "/mcp-rest/test/tools/list",
|
|
method: str = "POST",
|
|
json_body: Optional[Any] = None,
|
|
body: Optional[bytes] = None,
|
|
) -> Request:
|
|
headers = headers or {}
|
|
if json_body is not None:
|
|
body_bytes = json.dumps(json_body).encode("utf-8")
|
|
elif body is not None:
|
|
body_bytes = body
|
|
else:
|
|
body_bytes = b""
|
|
raw_headers = [(key.lower().encode("latin-1"), value.encode("latin-1")) for key, value in headers.items()]
|
|
scope = {
|
|
"type": "http",
|
|
"http_version": "1.1",
|
|
"method": method,
|
|
"path": path,
|
|
"headers": raw_headers,
|
|
}
|
|
|
|
state = {"sent": False}
|
|
|
|
async def receive():
|
|
if state["sent"]:
|
|
return {"type": "http.request", "body": b"", "more_body": False}
|
|
state["sent"] = True
|
|
return {"type": "http.request", "body": body_bytes, "more_body": False}
|
|
|
|
return Request(scope, receive=receive)
|
|
|
|
|
|
def _get_route(path: str, method: str):
|
|
for route in rest_endpoints.router.routes:
|
|
if getattr(route, "path", None) == path and method in getattr(route, "methods", set()):
|
|
return route
|
|
raise AssertionError(f"Route {method} {path} not found")
|
|
|
|
|
|
def _route_has_dependency(route, dependency) -> bool:
|
|
if any(getattr(dep, "dependency", None) == dependency for dep in getattr(route, "dependencies", [])):
|
|
return True
|
|
dependant = getattr(route, "dependant", None)
|
|
if dependant is None:
|
|
return False
|
|
return any(getattr(dep, "call", None) == dependency for dep in dependant.dependencies)
|
|
|
|
|
|
class TestExecuteWithMcpClient:
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("auth_type", "auth_value", "expected_auth"),
|
|
(
|
|
(MCPAuth.none, None, {}),
|
|
(MCPAuth.basic, "preview:correct", {"Authorization": "Basic cHJldmlldzpjb3JyZWN0"}),
|
|
(MCPAuth.basic, None, {"Authorization": "Basic cHJldmlldzpzdG9yZWQ="}),
|
|
(MCPAuth.bearer_token, "edited", {"Authorization": "Bearer edited"}),
|
|
(MCPAuth.api_key, "edited", {"X-API-Key": "edited"}),
|
|
(MCPAuth.token, "edited", {"Authorization": "token edited"}),
|
|
(MCPAuth.authorization, "Custom edited", {"Authorization": "Custom edited"}),
|
|
),
|
|
)
|
|
async def test_static_preview_uses_edited_connection_instead_of_registered_server(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
auth_type: MCPAuth,
|
|
auth_value: str | None,
|
|
expected_auth: dict[str, str],
|
|
) -> None:
|
|
from starlette.datastructures import Headers
|
|
|
|
from litellm.mcp_client.client import MCPClient
|
|
from litellm.proxy.management_endpoints import mcp_management_endpoints
|
|
from litellm.proxy.mcp_server.mcp_server_manager import MCPServerManager
|
|
|
|
saved: Final = MCPServer(
|
|
server_id="saved-preview-server",
|
|
name="saved",
|
|
url="https://stored.example/mcp",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.basic,
|
|
authentication_token="preview:stored",
|
|
)
|
|
manager: Final = MCPServerManager()
|
|
manager.registry = {saved.server_id: saved}
|
|
monkeypatch.setattr(rest_endpoints, "global_mcp_server_manager", manager)
|
|
monkeypatch.setattr(mcp_management_endpoints, "global_mcp_server_manager", manager)
|
|
payload: Final = NewMCPServerRequest(
|
|
server_id=saved.server_id,
|
|
server_name="edited",
|
|
url="https://stored.example/corrected-mcp",
|
|
transport=MCPTransport.sse,
|
|
auth_type=auth_type,
|
|
credentials={"auth_value": auth_value} if auth_value is not None else None,
|
|
static_headers={"X-Preview": "edited"},
|
|
)
|
|
staged: Final = rest_endpoints._stage_server_test(payload, Headers())
|
|
|
|
async def inspect_connection(client: MCPClient) -> dict[str, object]:
|
|
return {"url": client.server_url, "transport": client.transport_type, "headers": client._get_auth_headers()}
|
|
|
|
result: Final = await rest_endpoints._execute_with_mcp_client(
|
|
staged.request,
|
|
inspect_connection,
|
|
mcp_auth_header=staged.mcp_auth_header,
|
|
oauth2_headers=staged.oauth2_headers,
|
|
)
|
|
assert result == {
|
|
"url": "https://stored.example/corrected-mcp",
|
|
"transport": MCPTransport.sse,
|
|
"headers": {"X-Preview": "edited", **expected_auth},
|
|
}
|
|
assert manager.get_mcp_server_by_id(saved.server_id) is saved
|
|
assert saved.url == "https://stored.example/mcp"
|
|
|
|
@pytest.mark.parametrize(
|
|
("saved_url", "url", "same_origin"),
|
|
(
|
|
("https://stored.example/mcp", "https://other.example/mcp", False),
|
|
("https://stored.example/mcp", "http://stored.example/mcp", False),
|
|
("https://stored.example/mcp", "https://stored.example:8443/mcp", False),
|
|
("https://stored.example/mcp", "https://stored.example:443/mcp", True),
|
|
("http://stored.example/mcp", "http://stored.example:80/edited", True),
|
|
("https://stored.example/mcp", "HTTPS://STORED.EXAMPLE/edited", True),
|
|
("https://[::1]/mcp", "https://[::1]/edited", True),
|
|
("https://[::1]/mcp", "https://[::1]:443/edited", True),
|
|
("https://[::1]/mcp", "https://[::2]/edited", False),
|
|
("https://stored.example/mcp", "https://stored.example:invalid/mcp", False),
|
|
),
|
|
)
|
|
@pytest.mark.parametrize("explicit_credential", (None, "preview:explicit"))
|
|
def test_static_preview_respects_origin_when_inheriting_credentials(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
saved_url: str,
|
|
url: str,
|
|
same_origin: bool,
|
|
explicit_credential: str | None,
|
|
) -> None:
|
|
from starlette.datastructures import Headers
|
|
|
|
from litellm.proxy.management_endpoints import mcp_management_endpoints
|
|
from litellm.proxy.mcp_server.mcp_server_manager import MCPServerManager
|
|
|
|
saved: Final = MCPServer(
|
|
server_id="saved-preview-server",
|
|
name="saved",
|
|
url=saved_url,
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.basic,
|
|
authentication_token="preview:stored",
|
|
)
|
|
manager: Final = MCPServerManager()
|
|
manager.registry = {saved.server_id: saved}
|
|
monkeypatch.setattr(rest_endpoints, "global_mcp_server_manager", manager)
|
|
monkeypatch.setattr(mcp_management_endpoints, "global_mcp_server_manager", manager)
|
|
payload: Final = NewMCPServerRequest(
|
|
server_id=saved.server_id,
|
|
url=url,
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.basic,
|
|
credentials={"auth_value": explicit_credential} if explicit_credential else None,
|
|
)
|
|
staged: Final = rest_endpoints._stage_server_test(payload, Headers())
|
|
expected: Final = explicit_credential or ("preview:stored" if same_origin else None)
|
|
assert staged.mcp_auth_header == expected
|
|
assert staged.request.credentials == ({"auth_value": expected} if expected else None)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_redacts_stack_trace(self, monkeypatch):
|
|
async def fake_create_client(*args, **kwargs):
|
|
return object()
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"create_mcp_client",
|
|
fake_create_client,
|
|
)
|
|
|
|
async def failing_operation(client):
|
|
raise RuntimeError("boom")
|
|
|
|
payload = NewMCPServerRequest(
|
|
server_name="example",
|
|
url="https://example.com",
|
|
auth_type=MCPAuth.none,
|
|
)
|
|
|
|
result = await rest_endpoints._execute_with_mcp_client(payload, failing_operation)
|
|
|
|
assert result["status"] == "error"
|
|
assert "stack_trace" not in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_timeout_caps_hanging_operation_and_names_origin(self, monkeypatch):
|
|
async def fake_create_client(*args, **kwargs):
|
|
return object()
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"create_mcp_client",
|
|
fake_create_client,
|
|
)
|
|
|
|
async def hanging_operation(client):
|
|
await asyncio.Event().wait()
|
|
|
|
payload = NewMCPServerRequest(
|
|
server_name="example",
|
|
url="https://mcp.example.com/mcp/",
|
|
auth_type=MCPAuth.none,
|
|
)
|
|
|
|
result = await asyncio.wait_for(
|
|
rest_endpoints._execute_with_mcp_client(payload, hanging_operation, timeout_seconds=0.05),
|
|
timeout=5,
|
|
)
|
|
|
|
assert result["error"] is True
|
|
assert "https://mcp.example.com" in result["message"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_timeout_covers_client_creation(self, monkeypatch):
|
|
async def hanging_create_client(*args, **kwargs):
|
|
await asyncio.Event().wait()
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"create_mcp_client",
|
|
hanging_create_client,
|
|
)
|
|
|
|
async def unreached_operation(client):
|
|
return {"status": "ok"}
|
|
|
|
payload = NewMCPServerRequest(
|
|
server_name="example",
|
|
url="https://mcp.example.com/mcp/",
|
|
auth_type=MCPAuth.none,
|
|
)
|
|
|
|
result = await asyncio.wait_for(
|
|
rest_endpoints._execute_with_mcp_client(payload, unreached_operation, timeout_seconds=0.05),
|
|
timeout=5,
|
|
)
|
|
|
|
assert result["error"] is True
|
|
assert "https://mcp.example.com" in result["message"]
|
|
|
|
def test_timeout_defaults_to_tool_listing_timeout(self):
|
|
default = inspect.signature(rest_endpoints._execute_with_mcp_client).parameters["timeout_seconds"].default
|
|
assert default == MCP_TOOL_LISTING_TIMEOUT
|
|
|
|
def test_connection_error_message_timeout_names_origin_and_budget(self):
|
|
message = rest_endpoints._connection_error_message(TimeoutError(), "https://api.example.com/mcp/", 30.0)
|
|
assert "https://api.example.com" in message
|
|
assert "30s" in message
|
|
|
|
def test_connection_error_message_hides_arbitrary_http_exception_detail(self):
|
|
message = rest_endpoints._connection_error_message(
|
|
HTTPException(status_code=500, detail="secret upstream detail"),
|
|
"https://api.example.com/mcp/",
|
|
30.0,
|
|
)
|
|
|
|
assert "secret upstream detail" not in message
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_none_mode_url_credentials_returns_actionable_redacted_error(self):
|
|
async def unreached_operation(client):
|
|
raise AssertionError("operation must not run for an invalid server configuration")
|
|
|
|
payload = NewMCPServerRequest(
|
|
server_name="example",
|
|
url="https://lit-user:s3cr3t@upstream.example.com/mcp",
|
|
auth_type=MCPAuth.none,
|
|
)
|
|
|
|
result = await rest_endpoints._execute_with_mcp_client(payload, unreached_operation)
|
|
|
|
message = str(result["message"])
|
|
assert result["error"] is True
|
|
assert "Basic Auth" in message
|
|
assert "auth_type: basic" in message
|
|
assert "auth_value: username:password" in message
|
|
assert "lit-user" not in message
|
|
assert "s3cr3t" not in message
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_forwards_static_headers(self, monkeypatch):
|
|
"""Ensure static_headers are forwarded to the MCP client during test calls.
|
|
|
|
This is required for `/mcp-rest/test/tools/list` (Issue #19341), where the UI
|
|
sends `static_headers` but the backend must forward them during
|
|
`session.initialize()` and tool discovery.
|
|
"""
|
|
captured: dict = {}
|
|
|
|
def fake_build_stdio_env(server, raw_headers):
|
|
return None
|
|
|
|
async def fake_create_client(*args, **kwargs):
|
|
captured["extra_headers"] = kwargs.get("extra_headers")
|
|
return object()
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"build_stdio_env",
|
|
fake_build_stdio_env,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"create_mcp_client",
|
|
fake_create_client,
|
|
raising=False,
|
|
)
|
|
|
|
async def ok_operation(client):
|
|
return {"status": "ok"}
|
|
|
|
payload = NewMCPServerRequest(
|
|
server_name="example",
|
|
url="https://example.com",
|
|
auth_type=MCPAuth.none,
|
|
static_headers={"Authorization": "STATIC token"},
|
|
)
|
|
|
|
result = await rest_endpoints._execute_with_mcp_client(
|
|
payload,
|
|
ok_operation,
|
|
oauth2_headers={"X-OAuth": "1"},
|
|
raw_headers={"x-test": "y"},
|
|
)
|
|
|
|
assert result["status"] == "ok"
|
|
assert captured["extra_headers"] == {
|
|
"X-OAuth": "1",
|
|
"Authorization": "STATIC token",
|
|
}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_m2m_credentials_forwarded_to_server_model(self, monkeypatch):
|
|
"""M2M OAuth credentials (client_id, client_secret) from the nested
|
|
``credentials`` dict must be forwarded to the MCPServer model so that
|
|
``has_client_credentials`` returns True and the proxy auto-fetches tokens."""
|
|
captured: dict = {}
|
|
|
|
def fake_build_stdio_env(server, raw_headers):
|
|
return None
|
|
|
|
async def fake_create_client(*args, **kwargs):
|
|
captured["server"] = kwargs.get("server")
|
|
return object()
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"build_stdio_env",
|
|
fake_build_stdio_env,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"create_mcp_client",
|
|
fake_create_client,
|
|
raising=False,
|
|
)
|
|
|
|
async def ok_operation(client):
|
|
return {"status": "ok"}
|
|
|
|
payload = NewMCPServerRequest(
|
|
server_name="m2m-server",
|
|
url="https://example.com",
|
|
auth_type=MCPAuth.oauth2,
|
|
token_url="https://auth.example.com/token",
|
|
credentials={
|
|
"client_id": "my-id",
|
|
"client_secret": "my-secret",
|
|
"scopes": ["read", "write"],
|
|
},
|
|
)
|
|
|
|
result = await rest_endpoints._execute_with_mcp_client(payload, ok_operation)
|
|
|
|
assert result["status"] == "ok"
|
|
server = captured["server"]
|
|
assert server.client_id == "my-id"
|
|
assert server.client_secret == "my-secret"
|
|
assert server.token_url == "https://auth.example.com/token"
|
|
assert server.scopes == ["read", "write"]
|
|
assert server.has_client_credentials is True
|
|
|
|
async def test_preview_forwards_per_server_timeout_to_client_factory(self, monkeypatch):
|
|
"""The request's per-server timeout must reach the temporary MCPServer model:
|
|
the client factory reads ``server.timeout`` for both the per-request timeout
|
|
and the preview's whole-walk listing deadline."""
|
|
captured: dict = {}
|
|
|
|
def fake_build_stdio_env(server, raw_headers):
|
|
return None
|
|
|
|
async def fake_create_client(*args, **kwargs):
|
|
captured["server"] = kwargs.get("server")
|
|
return object()
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"build_stdio_env",
|
|
fake_build_stdio_env,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"create_mcp_client",
|
|
fake_create_client,
|
|
raising=False,
|
|
)
|
|
|
|
async def ok_operation(client):
|
|
return {"status": "ok"}
|
|
|
|
payload = NewMCPServerRequest(
|
|
server_name="slow-catalog-server",
|
|
url="https://example.com",
|
|
timeout=120.5,
|
|
)
|
|
|
|
result = await rest_endpoints._execute_with_mcp_client(payload, ok_operation)
|
|
|
|
assert result["status"] == "ok"
|
|
assert captured["server"].timeout == 120.5
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_m2m_drops_incoming_oauth2_headers(self, monkeypatch):
|
|
"""For M2M OAuth servers the incoming Authorization header (which carries
|
|
the litellm API key) must NOT be forwarded as extra_headers — otherwise
|
|
it overwrites the auto-fetched M2M token."""
|
|
captured: dict = {}
|
|
|
|
def fake_build_stdio_env(server, raw_headers):
|
|
return None
|
|
|
|
async def fake_create_client(*args, **kwargs):
|
|
captured["extra_headers"] = kwargs.get("extra_headers")
|
|
return object()
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"build_stdio_env",
|
|
fake_build_stdio_env,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"create_mcp_client",
|
|
fake_create_client,
|
|
raising=False,
|
|
)
|
|
|
|
async def ok_operation(client):
|
|
return {"status": "ok"}
|
|
|
|
payload = NewMCPServerRequest(
|
|
server_name="m2m-server",
|
|
url="https://example.com",
|
|
auth_type=MCPAuth.oauth2,
|
|
token_url="https://auth.example.com/token",
|
|
credentials={
|
|
"client_id": "my-id",
|
|
"client_secret": "my-secret",
|
|
},
|
|
)
|
|
|
|
incoming_oauth2 = {"Authorization": "Bearer sk-litellm-api-key"}
|
|
result = await rest_endpoints._execute_with_mcp_client(
|
|
payload,
|
|
ok_operation,
|
|
oauth2_headers=incoming_oauth2,
|
|
)
|
|
|
|
assert result["status"] == "ok"
|
|
# The incoming Authorization must be dropped — extra_headers should
|
|
# contain no oauth2 headers (only static_headers, which are None here).
|
|
assert captured["extra_headers"] is None or "Authorization" not in captured["extra_headers"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_interactive_oauth_resolves_forwarded_token_via_presented_store(self, monkeypatch):
|
|
"""Interactive authorization_code preview (oauth2, no client credentials): the forwarded
|
|
just-authorized token is resolved THROUGH the v2 resolver via a one-shot presented store
|
|
(cred_provider), not the caller-override path. The bare token (Bearer stripped) is the
|
|
upstream credential and is not also forwarded in extra_headers."""
|
|
captured: dict = {}
|
|
|
|
def fake_build_stdio_env(server, raw_headers):
|
|
return None
|
|
|
|
async def fake_create_client(*args, **kwargs):
|
|
captured.update(kwargs)
|
|
return object()
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"build_stdio_env",
|
|
fake_build_stdio_env,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"create_mcp_client",
|
|
fake_create_client,
|
|
raising=False,
|
|
)
|
|
|
|
async def ok_operation(client):
|
|
return {"status": "ok"}
|
|
|
|
payload = NewMCPServerRequest(
|
|
server_name="linear",
|
|
url="https://mcp.linear.app/mcp",
|
|
auth_type=MCPAuth.oauth2,
|
|
authorization_url="https://mcp.linear.app/authorize",
|
|
)
|
|
|
|
result = await rest_endpoints._execute_with_mcp_client(
|
|
payload,
|
|
ok_operation,
|
|
oauth2_headers={"Authorization": "Bearer forwarded-user-token"},
|
|
)
|
|
|
|
assert result["status"] == "ok"
|
|
# Resolved via the v2 resolver, never the caller-override header
|
|
assert captured["mcp_auth_header"] is None
|
|
provider = captured["cred_provider"]
|
|
assert provider is not None
|
|
token = await provider._oauth_token_store.fetch("u", "s")
|
|
assert token is not None and token.access_token == "forwarded-user-token"
|
|
# The resolver supplies the bearer, so it is not also forwarded as a caller header
|
|
extra_headers = captured.get("extra_headers") or {}
|
|
assert not any(k.lower() == "authorization" for k in extra_headers)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_m2m_does_not_build_presented_store(self, monkeypatch):
|
|
"""M2M (client_credentials): to_server_spec returns None, so no presented provider is built;
|
|
the auto-fetch path is unchanged (no cred_provider, the incoming header dropped as before).
|
|
"""
|
|
captured: dict = {}
|
|
|
|
def fake_build_stdio_env(server, raw_headers):
|
|
return None
|
|
|
|
async def fake_create_client(*args, **kwargs):
|
|
captured.update(kwargs)
|
|
return object()
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"build_stdio_env",
|
|
fake_build_stdio_env,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"create_mcp_client",
|
|
fake_create_client,
|
|
raising=False,
|
|
)
|
|
|
|
async def ok_operation(client):
|
|
return {"status": "ok"}
|
|
|
|
payload = NewMCPServerRequest(
|
|
server_name="m2m-server",
|
|
url="https://example.com",
|
|
auth_type=MCPAuth.oauth2,
|
|
token_url="https://auth.example.com/token",
|
|
credentials={"client_id": "my-id", "client_secret": "my-secret"},
|
|
)
|
|
|
|
result = await rest_endpoints._execute_with_mcp_client(
|
|
payload,
|
|
ok_operation,
|
|
oauth2_headers={"Authorization": "Bearer sk-litellm-api-key"},
|
|
)
|
|
|
|
assert result["status"] == "ok"
|
|
assert captured.get("cred_provider") is None
|
|
assert captured["mcp_auth_header"] is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_exchange_does_not_build_presented_store(self, monkeypatch):
|
|
"""OBO / token-exchange (auth_type oauth2_token_exchange, not oauth2): excluded by the
|
|
auth_type == oauth2 guard, so no presented provider is built and the v1 exchange path runs.
|
|
"""
|
|
captured: dict = {}
|
|
|
|
def fake_build_stdio_env(server, raw_headers):
|
|
return None
|
|
|
|
async def fake_create_client(*args, **kwargs):
|
|
captured.update(kwargs)
|
|
return object()
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"build_stdio_env",
|
|
fake_build_stdio_env,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"create_mcp_client",
|
|
fake_create_client,
|
|
raising=False,
|
|
)
|
|
|
|
async def ok_operation(client):
|
|
return {"status": "ok"}
|
|
|
|
payload = NewMCPServerRequest(
|
|
server_name="obo-server",
|
|
url="https://example.com",
|
|
auth_type=MCPAuth.oauth2_token_exchange,
|
|
token_url="https://auth.example.com/token",
|
|
)
|
|
|
|
result = await rest_endpoints._execute_with_mcp_client(
|
|
payload,
|
|
ok_operation,
|
|
oauth2_headers={"Authorization": "Bearer subject-jwt"},
|
|
)
|
|
|
|
assert result["status"] == "ok"
|
|
assert captured.get("cred_provider") is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_catches_exception_group(self, monkeypatch):
|
|
"""MCP SDK's anyio TaskGroup raises BaseExceptionGroup which does not
|
|
inherit from Exception. The handler must catch it and return an error
|
|
dict instead of letting a raw 500 propagate."""
|
|
|
|
def fake_build_stdio_env(server, raw_headers):
|
|
return None
|
|
|
|
async def fake_create_client(*args, **kwargs):
|
|
raise BaseExceptionGroup("test group", [RuntimeError("Cancelled via cancel scope")])
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"build_stdio_env",
|
|
fake_build_stdio_env,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"create_mcp_client",
|
|
fake_create_client,
|
|
raising=False,
|
|
)
|
|
|
|
async def ok_operation(client):
|
|
return {"status": "ok"}
|
|
|
|
payload = NewMCPServerRequest(
|
|
server_name="bad-server",
|
|
url="https://example.com",
|
|
auth_type=MCPAuth.none,
|
|
)
|
|
|
|
result = await rest_endpoints._execute_with_mcp_client(payload, ok_operation)
|
|
|
|
assert result["status"] == "error"
|
|
assert result["error"] is True
|
|
assert "reference" in result["message"]
|
|
# Error message must not leak raw exception details
|
|
assert "cancel scope" not in result["message"]
|
|
|
|
|
|
class TestTestConnection:
|
|
def test_requires_auth_dependency(self):
|
|
route = _get_route("/mcp-rest/test/connection", "POST")
|
|
assert _route_has_dependency(route, user_api_key_auth)
|
|
|
|
@staticmethod
|
|
def _capture_execute(monkeypatch) -> dict:
|
|
captured: dict = {}
|
|
|
|
async def fake_execute(
|
|
request,
|
|
operation,
|
|
mcp_auth_header=None,
|
|
oauth2_headers=None,
|
|
raw_headers=None,
|
|
):
|
|
captured["request"] = request
|
|
captured["mcp_auth_header"] = mcp_auth_header
|
|
captured["oauth2_headers"] = oauth2_headers
|
|
return {"status": "ok"}
|
|
|
|
monkeypatch.setattr(rest_endpoints, "_execute_with_mcp_client", fake_execute, raising=False)
|
|
return captured
|
|
|
|
@staticmethod
|
|
def _oauth2_authorization_code_payload(**overrides) -> NewMCPServerRequest:
|
|
return NewMCPServerRequest(
|
|
server_name="github_mcp",
|
|
url="https://api.githubcopilot.com/mcp/",
|
|
auth_type=MCPAuth.oauth2,
|
|
oauth2_flow="authorization_code",
|
|
authorization_url="https://github.com/login/oauth/authorize",
|
|
token_url="https://github.com/login/oauth/access_token",
|
|
**overrides,
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_forwards_staged_oauth2_bearer(self, monkeypatch):
|
|
"""The just-authorized upstream token rides the request's Authorization header, exactly
|
|
as /test/tools/list receives it; dropping it makes every authorization_code server fail
|
|
the connection test that its tools preview passes."""
|
|
from litellm.proxy._types import LitellmUserRoles
|
|
|
|
captured = self._capture_execute(monkeypatch)
|
|
request = _build_request(
|
|
{"x-litellm-api-key": "sk-admin-session", "authorization": "Bearer upstream-oauth-token"},
|
|
path="/mcp-rest/test/connection",
|
|
)
|
|
|
|
result = await rest_endpoints.test_connection(
|
|
request,
|
|
self._oauth2_authorization_code_payload(),
|
|
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
|
|
)
|
|
|
|
assert result == {"status": "ok"}
|
|
assert captured["oauth2_headers"] == {"Authorization": "Bearer upstream-oauth-token"}
|
|
assert captured["mcp_auth_header"] is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_forwards_staged_auth_value(self, monkeypatch):
|
|
from litellm.proxy._types import LitellmUserRoles
|
|
|
|
captured = self._capture_execute(monkeypatch)
|
|
request = _build_request({"x-litellm-api-key": "sk-admin-session"}, path="/mcp-rest/test/connection")
|
|
payload = NewMCPServerRequest(
|
|
server_name="example",
|
|
url="https://example.com/mcp",
|
|
auth_type=MCPAuth.bearer_token,
|
|
credentials={"auth_value": "upstream-static-token"},
|
|
)
|
|
|
|
result = await rest_endpoints.test_connection(
|
|
request,
|
|
payload,
|
|
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
|
|
)
|
|
|
|
assert result == {"status": "ok"}
|
|
assert captured["mcp_auth_header"] == "upstream-static-token"
|
|
assert captured["oauth2_headers"] is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inherits_stored_credentials_of_saved_server(self, monkeypatch):
|
|
"""The edit form resends a saved server without its masked credential; the stored one
|
|
must be used, as /test/tools/list already does."""
|
|
from litellm.proxy._types import LitellmUserRoles
|
|
from litellm.proxy.management_endpoints import mcp_management_endpoints
|
|
from litellm.proxy.mcp_server.mcp_server_manager import MCPServerManager
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
manager = MCPServerManager()
|
|
monkeypatch.setattr(rest_endpoints, "global_mcp_server_manager", manager)
|
|
monkeypatch.setattr(mcp_management_endpoints, "global_mcp_server_manager", manager)
|
|
captured = self._capture_execute(monkeypatch)
|
|
saved = MCPServer(
|
|
server_id="saved-server-id",
|
|
name="example",
|
|
url="https://example.com/mcp",
|
|
transport="http",
|
|
auth_type=MCPAuth.bearer_token,
|
|
authentication_token="stored-upstream-token",
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_id",
|
|
lambda server_id: saved if server_id == "saved-server-id" else None,
|
|
)
|
|
request = _build_request({"x-litellm-api-key": "sk-admin-session"}, path="/mcp-rest/test/connection")
|
|
payload = NewMCPServerRequest(
|
|
server_id="saved-server-id",
|
|
server_name="example",
|
|
url="https://example.com/mcp",
|
|
auth_type=MCPAuth.bearer_token,
|
|
)
|
|
|
|
result = await rest_endpoints.test_connection(
|
|
request,
|
|
payload,
|
|
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
|
|
)
|
|
|
|
assert result == {"status": "ok"}
|
|
assert captured["mcp_auth_header"] == "stored-upstream-token"
|
|
assert captured["request"].credentials == {"auth_value": "stored-upstream-token"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_does_not_forward_authorization_that_satisfied_admission(self, monkeypatch):
|
|
"""With no x-litellm-api-key, the Authorization value is the caller's LiteLLM key and
|
|
must never reach the upstream."""
|
|
from litellm.proxy._types import LitellmUserRoles
|
|
|
|
captured = self._capture_execute(monkeypatch)
|
|
request = _build_request({"authorization": "Bearer sk-litellm-admission-key"}, path="/mcp-rest/test/connection")
|
|
|
|
result = await rest_endpoints.test_connection(
|
|
request,
|
|
self._oauth2_authorization_code_payload(),
|
|
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
|
|
)
|
|
|
|
assert result == {"status": "ok"}
|
|
assert captured["oauth2_headers"] is None
|
|
|
|
|
|
class TestTestToolsList:
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
async def test_forwards_mcp_auth_header(self, monkeypatch):
|
|
"""Ensure credential-based auth forwards the auth_value to the MCP client."""
|
|
|
|
captured: dict = {}
|
|
|
|
async def fake_execute(
|
|
request,
|
|
operation,
|
|
mcp_auth_header=None,
|
|
oauth2_headers=None,
|
|
raw_headers=None,
|
|
):
|
|
captured["mcp_auth_header"] = mcp_auth_header
|
|
captured["oauth2_headers"] = oauth2_headers
|
|
return {
|
|
"tools": [],
|
|
"error": None,
|
|
"message": "Successfully retrieved tools",
|
|
}
|
|
|
|
monkeypatch.setattr(rest_endpoints, "_execute_with_mcp_client", fake_execute, raising=False)
|
|
|
|
oauth_call_counter = {"count": 0}
|
|
|
|
def fake_oauth(headers):
|
|
oauth_call_counter["count"] += 1
|
|
return {"Authorization": "Bearer oauth"}
|
|
|
|
monkeypatch.setattr(
|
|
auth_mcp.MCPRequestHandler,
|
|
"get_oauth2_headers_from_headers",
|
|
staticmethod(fake_oauth),
|
|
raising=False,
|
|
)
|
|
|
|
request = _build_request()
|
|
payload = NewMCPServerRequest(
|
|
server_name="example",
|
|
url="https://example.com",
|
|
auth_type=MCPAuth.api_key,
|
|
credentials={"auth_value": "secret-key"},
|
|
)
|
|
|
|
from litellm.proxy._types import LitellmUserRoles
|
|
|
|
result = await rest_endpoints.test_tools_list(
|
|
request,
|
|
payload,
|
|
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
|
|
)
|
|
|
|
assert result["message"] == "Successfully retrieved tools"
|
|
assert captured["mcp_auth_header"] == "secret-key"
|
|
assert captured["oauth2_headers"] is None
|
|
assert oauth_call_counter["count"] == 0
|
|
|
|
async def test_preview_tools_list_times_out_on_slow_pagination(self, monkeypatch):
|
|
"""A preview whose upstream paginates past the listing deadline returns a
|
|
timeout error instead of holding the request open."""
|
|
monkeypatch.setattr(rest_endpoints, "MCP_CLIENT_TIMEOUT", 0.05, raising=False)
|
|
monkeypatch.setattr(rest_endpoints, "MCP_TOOL_LISTING_TIMEOUT", 0.05, raising=False)
|
|
|
|
class SlowClient:
|
|
async def list_tools(self, raise_on_error=False):
|
|
await asyncio.sleep(1)
|
|
return []
|
|
|
|
async def fake_execute(
|
|
request,
|
|
operation,
|
|
mcp_auth_header=None,
|
|
oauth2_headers=None,
|
|
raw_headers=None,
|
|
):
|
|
return await operation(SlowClient())
|
|
|
|
monkeypatch.setattr(rest_endpoints, "_execute_with_mcp_client", fake_execute, raising=False)
|
|
|
|
from litellm.proxy._types import LitellmUserRoles
|
|
|
|
request = _build_request()
|
|
payload = NewMCPServerRequest(
|
|
server_name="example",
|
|
url="https://example.com",
|
|
auth_type=MCPAuth.api_key,
|
|
credentials={"auth_value": "secret-key"},
|
|
)
|
|
|
|
result = await rest_endpoints.test_tools_list(
|
|
request,
|
|
payload,
|
|
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
|
|
)
|
|
|
|
assert result["status"] == "error"
|
|
assert result["error"] is True
|
|
assert "Timed out listing tools" in result["message"]
|
|
|
|
async def test_preview_tools_list_succeeds_within_deadline(self, monkeypatch):
|
|
"""The preview timeout scope passes a fast listing through untouched."""
|
|
from mcp.types import Tool as MCPTool
|
|
|
|
class QuickClient:
|
|
async def list_tools(self, raise_on_error=False):
|
|
return [MCPTool(name="quick_tool", description="q", inputSchema={})]
|
|
|
|
async def fake_execute(
|
|
request,
|
|
operation,
|
|
mcp_auth_header=None,
|
|
oauth2_headers=None,
|
|
raw_headers=None,
|
|
):
|
|
return await operation(QuickClient())
|
|
|
|
monkeypatch.setattr(rest_endpoints, "_execute_with_mcp_client", fake_execute, raising=False)
|
|
|
|
from litellm.proxy._types import LitellmUserRoles
|
|
|
|
request = _build_request()
|
|
payload = NewMCPServerRequest(
|
|
server_name="example",
|
|
url="https://example.com",
|
|
auth_type=MCPAuth.api_key,
|
|
credentials={"auth_value": "secret-key"},
|
|
)
|
|
|
|
result = await rest_endpoints.test_tools_list(
|
|
request,
|
|
payload,
|
|
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
|
|
)
|
|
|
|
assert result["error"] is None
|
|
assert result["message"] == "Successfully retrieved tools"
|
|
assert [tool["name"] for tool in result["tools"]] == ["quick_tool"]
|
|
|
|
async def test_preview_tools_list_honors_per_server_timeout(self, monkeypatch):
|
|
"""A per-server timeout above the global default extends the preview deadline."""
|
|
monkeypatch.setattr(rest_endpoints, "MCP_CLIENT_TIMEOUT", 0.05, raising=False)
|
|
monkeypatch.setattr(rest_endpoints, "MCP_TOOL_LISTING_TIMEOUT", 0.05, raising=False)
|
|
|
|
from mcp.types import Tool as MCPTool
|
|
|
|
class SlowConfiguredClient:
|
|
timeout = 1.0
|
|
|
|
async def list_tools(self, raise_on_error=False):
|
|
await asyncio.sleep(0.2)
|
|
return [MCPTool(name="slow_tool", description="s", inputSchema={})]
|
|
|
|
async def fake_execute(
|
|
request,
|
|
operation,
|
|
mcp_auth_header=None,
|
|
oauth2_headers=None,
|
|
raw_headers=None,
|
|
):
|
|
return await operation(SlowConfiguredClient())
|
|
|
|
monkeypatch.setattr(rest_endpoints, "_execute_with_mcp_client", fake_execute, raising=False)
|
|
|
|
from litellm.proxy._types import LitellmUserRoles
|
|
|
|
request = _build_request()
|
|
payload = NewMCPServerRequest(
|
|
server_name="example",
|
|
url="https://example.com",
|
|
auth_type=MCPAuth.api_key,
|
|
credentials={"auth_value": "secret-key"},
|
|
)
|
|
|
|
result = await rest_endpoints.test_tools_list(
|
|
request,
|
|
payload,
|
|
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
|
|
)
|
|
|
|
assert result["error"] is None
|
|
assert [tool["name"] for tool in result["tools"]] == ["slow_tool"]
|
|
|
|
async def test_extracts_oauth2_headers(self, monkeypatch):
|
|
"""Ensure oauth2 auth type pulls oauth headers and omits MCP auth header."""
|
|
|
|
captured: dict = {}
|
|
|
|
async def fake_execute(
|
|
request,
|
|
operation,
|
|
mcp_auth_header=None,
|
|
oauth2_headers=None,
|
|
raw_headers=None,
|
|
):
|
|
captured["mcp_auth_header"] = mcp_auth_header
|
|
captured["oauth2_headers"] = oauth2_headers
|
|
return {
|
|
"tools": [],
|
|
"error": None,
|
|
"message": "Successfully retrieved tools",
|
|
}
|
|
|
|
monkeypatch.setattr(rest_endpoints, "_execute_with_mcp_client", fake_execute, raising=False)
|
|
|
|
oauth_headers = {"Authorization": "Bearer oauth"}
|
|
oauth_call_counter = {"count": 0}
|
|
|
|
def fake_oauth(headers):
|
|
oauth_call_counter["count"] += 1
|
|
return oauth_headers
|
|
|
|
monkeypatch.setattr(
|
|
auth_mcp.MCPRequestHandler,
|
|
"get_oauth2_headers_from_headers",
|
|
staticmethod(fake_oauth),
|
|
raising=False,
|
|
)
|
|
|
|
request = _build_request({"authorization": "Bearer incoming", "x-litellm-api-key": "sk-admission"})
|
|
payload = NewMCPServerRequest(
|
|
server_name="example",
|
|
url="https://example.com",
|
|
auth_type=MCPAuth.oauth2,
|
|
)
|
|
|
|
from litellm.proxy._types import LitellmUserRoles
|
|
|
|
result = await rest_endpoints.test_tools_list(
|
|
request,
|
|
payload,
|
|
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
|
|
)
|
|
|
|
assert result["message"] == "Successfully retrieved tools"
|
|
assert captured["mcp_auth_header"] is None
|
|
assert captured["oauth2_headers"] == oauth_headers
|
|
assert oauth_call_counter["count"] == 1
|
|
|
|
@pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate])
|
|
async def test_extracts_oauth2_headers_for_client_forwarded_modes(self, monkeypatch, auth_type):
|
|
"""The browser-only authorize flow sends the upstream token as Authorization; the preview
|
|
must thread it through for the client-forwarded token modes so the passthrough arm can
|
|
forward it, instead of probing the upstream unauthenticated."""
|
|
|
|
captured: dict = {}
|
|
|
|
async def fake_execute(
|
|
request,
|
|
operation,
|
|
mcp_auth_header=None,
|
|
oauth2_headers=None,
|
|
raw_headers=None,
|
|
):
|
|
captured["mcp_auth_header"] = mcp_auth_header
|
|
captured["oauth2_headers"] = oauth2_headers
|
|
return {
|
|
"tools": [],
|
|
"error": None,
|
|
"message": "Successfully retrieved tools",
|
|
}
|
|
|
|
monkeypatch.setattr(rest_endpoints, "_execute_with_mcp_client", fake_execute, raising=False)
|
|
|
|
oauth_headers = {"Authorization": "Bearer upstream-token"}
|
|
|
|
monkeypatch.setattr(
|
|
auth_mcp.MCPRequestHandler,
|
|
"get_oauth2_headers_from_headers",
|
|
staticmethod(lambda headers: oauth_headers),
|
|
raising=False,
|
|
)
|
|
|
|
request = _build_request({"authorization": "Bearer upstream-token", "x-litellm-api-key": "sk-admission"})
|
|
payload = NewMCPServerRequest(
|
|
server_name="example",
|
|
url="https://example.com",
|
|
auth_type=auth_type,
|
|
)
|
|
|
|
from litellm.proxy._types import LitellmUserRoles
|
|
|
|
result = await rest_endpoints.test_tools_list(
|
|
request,
|
|
payload,
|
|
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
|
|
)
|
|
|
|
assert result["message"] == "Successfully retrieved tools"
|
|
assert captured["mcp_auth_header"] is None
|
|
assert captured["oauth2_headers"] == oauth_headers
|
|
|
|
@pytest.mark.parametrize("auth_type", [MCPAuth.oauth2, MCPAuth.true_passthrough, MCPAuth.oauth_delegate])
|
|
async def test_does_not_forward_authorization_that_satisfied_admission(self, monkeypatch, auth_type):
|
|
"""Authorization is also the admission fallback: with no x-litellm-api-key on the request,
|
|
the Authorization value is the caller's LiteLLM key, so forwarding it would send the
|
|
admission credential to the upstream."""
|
|
|
|
captured: dict = {}
|
|
|
|
async def fake_execute(
|
|
request,
|
|
operation,
|
|
mcp_auth_header=None,
|
|
oauth2_headers=None,
|
|
raw_headers=None,
|
|
):
|
|
captured["oauth2_headers"] = oauth2_headers
|
|
return {
|
|
"tools": [],
|
|
"error": None,
|
|
"message": "Successfully retrieved tools",
|
|
}
|
|
|
|
monkeypatch.setattr(rest_endpoints, "_execute_with_mcp_client", fake_execute, raising=False)
|
|
|
|
request = _build_request({"authorization": "Bearer sk-litellm-admission-key"})
|
|
payload = NewMCPServerRequest(
|
|
server_name="example",
|
|
url="https://example.com",
|
|
auth_type=auth_type,
|
|
)
|
|
|
|
from litellm.proxy._types import LitellmUserRoles
|
|
|
|
result = await rest_endpoints.test_tools_list(
|
|
request,
|
|
payload,
|
|
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
|
|
)
|
|
|
|
assert result["message"] == "Successfully retrieved tools"
|
|
assert captured["oauth2_headers"] is None
|
|
|
|
|
|
class TestListToolsRestAPI:
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
async def test_single_server_rate_limit_returns_429_without_fetching_tools(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitError
|
|
from litellm.proxy.mcp_server.mcp_server_manager import MCPServerManager
|
|
|
|
server: Final = MCPServer(
|
|
server_id="rate-limited-server",
|
|
name="rate-limited-server",
|
|
server_name="rate-limited-server",
|
|
transport=MCPTransport.http,
|
|
)
|
|
caller: Final = UserAPIKeyAuth()
|
|
manager: Final = MCPServerManager()
|
|
monkeypatch.setitem(manager.registry, server.server_id, server)
|
|
enforcement: Final = AsyncMock(side_effect=ProxyRateLimitError(detail="server RPM exceeded"))
|
|
proxy_logging: Final = MagicMock(enforce_mcp_server_rate_limits=enforcement)
|
|
upstream: Final = AsyncMock(return_value=[Tool(name="should-not-list", inputSchema={})])
|
|
|
|
async def allowed_servers(*_: object, **__: object) -> list[str]:
|
|
return [server.server_id]
|
|
|
|
monkeypatch.setattr(rest_endpoints, "global_mcp_server_manager", manager)
|
|
monkeypatch.setattr(rest_endpoints, "build_effective_auth_contexts", AsyncMock(return_value=[caller]))
|
|
monkeypatch.setattr("litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging)
|
|
monkeypatch.setattr(manager, "get_allowed_mcp_servers", allowed_servers)
|
|
monkeypatch.setattr(manager, "filter_server_ids_by_ip_with_info", lambda ids, _ip: (ids, 0))
|
|
monkeypatch.setattr(manager, "get_tools_from_server", upstream)
|
|
|
|
with pytest.raises(HTTPException) as error:
|
|
await rest_endpoints.list_tool_rest_api(
|
|
_build_request(path="/mcp-rest/tools/list", method="GET"),
|
|
server_id=server.server_id,
|
|
user_api_key_dict=caller,
|
|
)
|
|
|
|
assert error.value.status_code == 429
|
|
enforcement.assert_awaited_once_with(caller, server)
|
|
upstream.assert_not_awaited()
|
|
|
|
async def test_admin_unfiltered_tools_list_does_not_enforce_server_rpm(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
from litellm.proxy._types import LitellmUserRoles
|
|
from litellm.proxy.mcp_server.mcp_server_manager import MCPServerManager
|
|
|
|
server: Final = MCPServer(
|
|
server_id="admin-unfiltered-server",
|
|
name="admin-unfiltered-server",
|
|
server_name="admin-unfiltered-server",
|
|
transport=MCPTransport.http,
|
|
allowed_tools=["enabled-tool"],
|
|
)
|
|
caller: Final = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)
|
|
manager: Final = MCPServerManager()
|
|
monkeypatch.setitem(manager.registry, server.server_id, server)
|
|
enforcement: Final = AsyncMock()
|
|
proxy_logging: Final = MagicMock(enforce_mcp_server_rate_limits=enforcement)
|
|
upstream: Final = AsyncMock(return_value=[Tool(name="disabled-tool", inputSchema={})])
|
|
|
|
async def allowed_servers(*_: object, **__: object) -> list[str]:
|
|
return [server.server_id]
|
|
|
|
monkeypatch.setattr(rest_endpoints, "global_mcp_server_manager", manager)
|
|
monkeypatch.setattr(rest_endpoints, "build_effective_auth_contexts", AsyncMock(return_value=[caller]))
|
|
monkeypatch.setattr("litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging)
|
|
monkeypatch.setattr(manager, "get_allowed_mcp_servers", allowed_servers)
|
|
monkeypatch.setattr(manager, "filter_server_ids_by_ip_with_info", lambda ids, _ip: (ids, 0))
|
|
monkeypatch.setattr(manager, "get_tools_from_server", upstream)
|
|
|
|
result: Final = await rest_endpoints.list_tool_rest_api(
|
|
_build_request(path="/mcp-rest/tools/list", method="GET"),
|
|
server_id=server.server_id,
|
|
include_disabled_tools=True,
|
|
user_api_key_dict=caller,
|
|
)
|
|
|
|
assert [tool.name for tool in result["tools"]] == ["disabled-tool"]
|
|
enforcement.assert_not_awaited()
|
|
upstream.assert_awaited_once()
|
|
|
|
async def test_rejects_disallowed_server(self, monkeypatch):
|
|
async def fake_contexts(user_api_key_auth):
|
|
return [user_api_key_auth]
|
|
|
|
async def fake_get_allowed_mcp_servers(*args, **kwargs):
|
|
return []
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"build_effective_auth_contexts",
|
|
fake_contexts,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_allowed_mcp_servers",
|
|
fake_get_allowed_mcp_servers,
|
|
raising=False,
|
|
)
|
|
|
|
request = _build_request(path="/mcp-rest/tools/list", method="GET")
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await rest_endpoints.list_tool_rest_api(
|
|
request,
|
|
server_id="server-1",
|
|
user_api_key_dict=UserAPIKeyAuth(),
|
|
)
|
|
|
|
assert exc_info.value.status_code == 403
|
|
assert exc_info.value.detail["error"] == "access_denied"
|
|
assert "server-1" in exc_info.value.detail["message"]
|
|
|
|
async def test_lists_tools_for_allowed_server(self, monkeypatch):
|
|
async def fake_contexts(user_api_key_auth):
|
|
return [user_api_key_auth]
|
|
|
|
async def fake_get_allowed_mcp_servers(*args, **kwargs):
|
|
return ["server-1"]
|
|
|
|
class StubServer:
|
|
alias = "server-1"
|
|
server_name = "server-1"
|
|
name = "stub"
|
|
allowed_tools = None
|
|
mcp_info = {"server_name": "stub"}
|
|
available_on_public_internet = True
|
|
|
|
stub_server = StubServer()
|
|
|
|
captured = {"called": False}
|
|
|
|
async def fake_get_tools(
|
|
server,
|
|
server_auth_header,
|
|
raw_headers=None,
|
|
user_api_key_auth=None,
|
|
extra_headers=None,
|
|
apply_tool_filters=True,
|
|
client_ip=None,
|
|
):
|
|
captured["called"] = True
|
|
captured["server"] = server
|
|
captured["auth_header"] = server_auth_header
|
|
return ["tool-1"]
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"build_effective_auth_contexts",
|
|
fake_contexts,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_allowed_mcp_servers",
|
|
fake_get_allowed_mcp_servers,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_id",
|
|
lambda server_id: stub_server if server_id == "server-1" else None,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"_get_tools_for_single_server",
|
|
fake_get_tools,
|
|
raising=False,
|
|
)
|
|
|
|
request = _build_request(path="/mcp-rest/tools/list", method="GET")
|
|
result = await rest_endpoints.list_tool_rest_api(
|
|
request,
|
|
server_id="server-1",
|
|
user_api_key_dict=UserAPIKeyAuth(),
|
|
)
|
|
|
|
assert captured["called"] is True
|
|
assert captured["server"] is stub_server
|
|
assert result["tools"] == ["tool-1"]
|
|
assert result["error"] is None
|
|
|
|
async def test_non_admin_ui_session_resolves_as_admitted_subject(self, monkeypatch):
|
|
"""LIT-4861: a non-admin dashboard session must act as the admitted subject on this
|
|
route, so server reachability AND tool ceilings bind to the user's grants exactly as
|
|
they do for a gateway session, never to the bare session key."""
|
|
from litellm.constants import UI_SESSION_TOKEN_TEAM_ID
|
|
|
|
session_auth = UserAPIKeyAuth(team_id=UI_SESSION_TOKEN_TEAM_ID, user_id="grant-user", user_role="internal_user")
|
|
admitted_auth = UserAPIKeyAuth(user_id="grant-user", org_id="admitted-org")
|
|
|
|
async def fake_reload(user_id, *, requires_fresh_policy=False):
|
|
assert user_id == "grant-user"
|
|
assert requires_fresh_policy is False
|
|
return admitted_auth
|
|
|
|
monkeypatch.setattr(
|
|
"litellm.proxy.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler.reload_admitted_user",
|
|
fake_reload,
|
|
)
|
|
|
|
seen_server_resolution_auths = []
|
|
|
|
async def fake_get_allowed_mcp_servers(user_api_key_auth=None, **kwargs):
|
|
seen_server_resolution_auths.append(user_api_key_auth)
|
|
return ["server-1"]
|
|
|
|
class StubServer:
|
|
alias = "server-1"
|
|
server_name = "server-1"
|
|
name = "stub"
|
|
allowed_tools = None
|
|
mcp_info = {"server_name": "stub"}
|
|
available_on_public_internet = True
|
|
|
|
stub_server = StubServer()
|
|
captured = {}
|
|
|
|
async def fake_get_tools(
|
|
server,
|
|
server_auth_header,
|
|
raw_headers=None,
|
|
user_api_key_auth=None,
|
|
extra_headers=None,
|
|
apply_tool_filters=True,
|
|
client_ip=None,
|
|
):
|
|
captured["user_api_key_auth"] = user_api_key_auth
|
|
return ["tool-1"]
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_allowed_mcp_servers",
|
|
fake_get_allowed_mcp_servers,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_id",
|
|
lambda server_id: stub_server if server_id == "server-1" else None,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"_get_tools_for_single_server",
|
|
fake_get_tools,
|
|
raising=False,
|
|
)
|
|
|
|
request = _build_request(path="/mcp-rest/tools/list", method="GET")
|
|
result = await rest_endpoints.list_tool_rest_api(
|
|
request,
|
|
server_id="server-1",
|
|
user_api_key_dict=session_auth,
|
|
)
|
|
|
|
resolved = [*seen_server_resolution_auths, captured["user_api_key_auth"]]
|
|
assert seen_server_resolution_auths
|
|
assert all(a.org_id == "admitted-org" and a.team_id is None for a in resolved)
|
|
assert result["tools"] == ["tool-1"]
|
|
assert result["message"] == "Successfully retrieved tools"
|
|
|
|
async def test_toolset_scoped_request_keeps_the_caller_credential(self, monkeypatch):
|
|
"""LIT-4861: the admitted subject resolves per grant source and a team source deliberately
|
|
carries none of the caller's own object_permission, so a toolset narrowing layered on top
|
|
would evaporate on every team-granted server. A toolset-scoped request therefore stays on
|
|
the caller's own credential, exactly as it did before the acting-as-user swap."""
|
|
from litellm.constants import UI_SESSION_TOKEN_TEAM_ID
|
|
from litellm.proxy._types import LiteLLM_ObjectPermissionTable
|
|
|
|
session_auth = UserAPIKeyAuth(team_id=UI_SESSION_TOKEN_TEAM_ID, user_id="grant-user", user_role="internal_user")
|
|
scoped_auth = UserAPIKeyAuth(
|
|
object_permission=LiteLLM_ObjectPermissionTable(
|
|
object_permission_id="toolset-scope",
|
|
mcp_servers=["toolset-server-1"],
|
|
)
|
|
)
|
|
reload_calls: list[str] = []
|
|
scope_inputs: list[UserAPIKeyAuth] = []
|
|
|
|
async def record_reload(user_id):
|
|
reload_calls.append(user_id)
|
|
return UserAPIKeyAuth(user_id=user_id)
|
|
|
|
class StubToolset:
|
|
toolset_id = "toolset-1"
|
|
|
|
class StubServer:
|
|
alias = "toolset-server-1"
|
|
server_name = "toolset-server-1"
|
|
name = "toolset-server-1"
|
|
allowed_tools = None
|
|
mcp_info = {"server_name": "toolset-server-1"}
|
|
available_on_public_internet = True
|
|
|
|
stub_server = StubServer()
|
|
|
|
async def fake_get_toolset_by_name_cached(prisma_client, toolset_name):
|
|
return StubToolset()
|
|
|
|
async def fake_apply_toolset_scope(user_api_key_auth, toolset_id):
|
|
scope_inputs.append(user_api_key_auth)
|
|
return scoped_auth
|
|
|
|
async def fake_get_allowed_mcp_servers(user_api_key_auth=None, **kwargs):
|
|
assert user_api_key_auth is scoped_auth
|
|
return ["toolset-server-1"]
|
|
|
|
async def fake_get_tools(server, server_auth_header, *args, **kwargs):
|
|
return ["toolset-tool-1"]
|
|
|
|
monkeypatch.setattr(
|
|
"litellm.proxy.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler.reload_admitted_user",
|
|
record_reload,
|
|
)
|
|
monkeypatch.setattr(
|
|
"litellm.proxy.utils.get_prisma_client_or_throw",
|
|
lambda *args, **kwargs: MagicMock(),
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_toolset_by_name_cached",
|
|
fake_get_toolset_by_name_cached,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(rest_endpoints, "apply_toolset_scope", fake_apply_toolset_scope, raising=False)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_allowed_mcp_servers",
|
|
fake_get_allowed_mcp_servers,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_id",
|
|
lambda server_id: stub_server if server_id == "toolset-server-1" else None,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(rest_endpoints, "_get_tools_for_single_server", fake_get_tools, raising=False)
|
|
|
|
request = _build_request(path="/mcp-rest/tools/list", method="GET")
|
|
result = await rest_endpoints.list_tool_rest_api(
|
|
request,
|
|
server_id=None,
|
|
toolset_name="research_tools",
|
|
user_api_key_dict=session_auth,
|
|
)
|
|
|
|
assert result["tools"] == ["toolset-tool-1"]
|
|
assert scope_inputs == [session_auth]
|
|
assert reload_calls == []
|
|
|
|
async def test_single_server_response_includes_paginated_upstream_tools(
|
|
self,
|
|
monkeypatch,
|
|
):
|
|
"""The REST tools/list path should include tools beyond the upstream first page."""
|
|
from mcp.types import (
|
|
Implementation,
|
|
InitializeResult,
|
|
ListToolsResult,
|
|
PaginatedRequestParams,
|
|
ServerCapabilities,
|
|
)
|
|
from mcp.types import Tool as MCPTool
|
|
|
|
import litellm.mcp_client.client as mcp_client_module
|
|
from litellm.proxy.mcp_server.mcp_server_manager import MCPServerManager
|
|
from litellm.proxy.mcp_server.server import MCPServer
|
|
from litellm.types.mcp import MCPTransport
|
|
|
|
monkeypatch.setattr(rest_endpoints, "global_mcp_server_manager", MCPServerManager())
|
|
|
|
async def fake_contexts(user_api_key_auth):
|
|
return [user_api_key_auth]
|
|
|
|
async def fake_get_allowed_mcp_servers(*args, **kwargs):
|
|
return ["server-1"]
|
|
|
|
stub_server = MCPServer(
|
|
server_id="server-1",
|
|
name="stub",
|
|
server_name="stub",
|
|
alias="stub",
|
|
url="https://example.com/mcp",
|
|
transport=MCPTransport.http,
|
|
mcp_info={"server_name": "stub"},
|
|
)
|
|
stub_server.available_on_public_internet = True
|
|
monkeypatch.setattr(rest_endpoints.global_mcp_server_manager, "registry", {"server-1": stub_server})
|
|
|
|
mock_transport_ctx = AsyncMock()
|
|
mock_transport_ctx.__aenter__ = AsyncMock(return_value=(MagicMock(), MagicMock()))
|
|
mock_transport_ctx.__aexit__ = AsyncMock(return_value=None)
|
|
monkeypatch.setattr(
|
|
mcp_client_module,
|
|
"streamable_http_client",
|
|
MagicMock(return_value=mock_transport_ctx),
|
|
raising=False,
|
|
)
|
|
|
|
mock_session_ctx = AsyncMock()
|
|
mock_session_instance = AsyncMock()
|
|
mock_session_instance.initialize = AsyncMock(
|
|
return_value=InitializeResult(
|
|
protocol_version="2025-11-25",
|
|
capabilities=ServerCapabilities(),
|
|
server_info=Implementation(name="stub", version="1"),
|
|
)
|
|
)
|
|
mock_session_instance.list_tools.side_effect = [
|
|
ListToolsResult(
|
|
tools=[
|
|
MCPTool(
|
|
name="first_page_tool",
|
|
description="First page tool",
|
|
inputSchema={},
|
|
)
|
|
],
|
|
nextCursor="page-2",
|
|
),
|
|
ListToolsResult(
|
|
tools=[
|
|
MCPTool(
|
|
name="second_page_tool",
|
|
description="Second page tool",
|
|
inputSchema={},
|
|
)
|
|
]
|
|
),
|
|
]
|
|
mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_session_instance)
|
|
mock_session_ctx.__aexit__ = AsyncMock(return_value=None)
|
|
monkeypatch.setattr(
|
|
mcp_client_module,
|
|
"ClientSession",
|
|
MagicMock(return_value=mock_session_ctx),
|
|
raising=False,
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"build_effective_auth_contexts",
|
|
fake_contexts,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_allowed_mcp_servers",
|
|
fake_get_allowed_mcp_servers,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"filter_server_ids_by_ip_with_info",
|
|
lambda server_ids, client_ip: (server_ids, 0),
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"registry",
|
|
{stub_server.server_id: stub_server},
|
|
)
|
|
|
|
request = _build_request(path="/mcp-rest/tools/list", method="GET")
|
|
result = await rest_endpoints.list_tool_rest_api(
|
|
request,
|
|
server_id="server-1",
|
|
user_api_key_dict=UserAPIKeyAuth(),
|
|
)
|
|
|
|
assert set(result.keys()) == {"tools", "error", "message"}
|
|
assert [tool.name for tool in result["tools"]] == [
|
|
"first_page_tool",
|
|
"second_page_tool",
|
|
]
|
|
assert result["error"] is None
|
|
assert result["message"] == "Successfully retrieved tools"
|
|
|
|
assert mock_session_instance.list_tools.call_count == 2
|
|
second_call_params = mock_session_instance.list_tools.call_args_list[1].kwargs["params"]
|
|
assert isinstance(second_call_params, PaginatedRequestParams)
|
|
assert second_call_params.cursor == "page-2"
|
|
|
|
async def test_include_disabled_tools_is_admin_only(self, monkeypatch):
|
|
"""include_disabled_tools skips the allowlist filter only for PROXY_ADMIN;
|
|
a non-admin passing it stays filtered so the REST endpoint can't be used
|
|
to enumerate deliberately-disabled tools."""
|
|
from litellm.proxy._types import LitellmUserRoles
|
|
|
|
async def fake_contexts(user_api_key_auth):
|
|
return [user_api_key_auth]
|
|
|
|
async def fake_get_allowed_mcp_servers(*args, **kwargs):
|
|
return ["server-1"]
|
|
|
|
class StubServer:
|
|
alias = "server-1"
|
|
server_name = "server-1"
|
|
name = "stub"
|
|
allowed_tools = ["tool1"]
|
|
mcp_info = {"server_name": "stub"}
|
|
available_on_public_internet = True
|
|
|
|
stub_server = StubServer()
|
|
captured = {}
|
|
|
|
async def fake_get_tools(server, server_auth_header, *args, apply_tool_filters=True, **kwargs):
|
|
captured["apply_tool_filters"] = apply_tool_filters
|
|
return ["tool-1"]
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"build_effective_auth_contexts",
|
|
fake_contexts,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_allowed_mcp_servers",
|
|
fake_get_allowed_mcp_servers,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_id",
|
|
lambda server_id: stub_server if server_id == "server-1" else None,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"_get_tools_for_single_server",
|
|
fake_get_tools,
|
|
raising=False,
|
|
)
|
|
|
|
request = _build_request(path="/mcp-rest/tools/list", method="GET")
|
|
|
|
await rest_endpoints.list_tool_rest_api(
|
|
request,
|
|
server_id="server-1",
|
|
include_disabled_tools=True,
|
|
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
|
|
)
|
|
assert captured["apply_tool_filters"] is False
|
|
|
|
await rest_endpoints.list_tool_rest_api(
|
|
request,
|
|
server_id="server-1",
|
|
include_disabled_tools=True,
|
|
user_api_key_dict=UserAPIKeyAuth(),
|
|
)
|
|
assert captured["apply_tool_filters"] is True
|
|
|
|
@pytest.mark.parametrize("upstream_status", [401, 403])
|
|
async def test_upstream_auth_failure_surfaces_status_and_challenge(self, monkeypatch, upstream_status):
|
|
"""A single-server pass-through request whose upstream rejects the token
|
|
must surface the upstream status (401 or 403) plus its WWW-Authenticate
|
|
challenge, not collapse into a 200 ``unexpected_error`` body."""
|
|
from litellm.proxy.mcp_server.exceptions import (
|
|
MCPUpstreamAuthError,
|
|
)
|
|
|
|
class StubServer:
|
|
alias = "server-1"
|
|
server_name = "server-1"
|
|
name = "passthrough"
|
|
allowed_tools = None
|
|
mcp_info = {"server_name": "passthrough"}
|
|
available_on_public_internet = True
|
|
|
|
stub_server = StubServer()
|
|
|
|
async def fake_contexts(user_api_key_auth):
|
|
return [user_api_key_auth]
|
|
|
|
async def fake_get_allowed_mcp_servers(*args, **kwargs):
|
|
return ["server-1"]
|
|
|
|
challenge = 'Bearer resource_metadata="https://upstream/.well-known"'
|
|
|
|
async def fake_get_tools(*args, **kwargs):
|
|
raise MCPUpstreamAuthError(
|
|
status_code=upstream_status,
|
|
www_authenticate=challenge,
|
|
server_name="passthrough",
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"build_effective_auth_contexts",
|
|
fake_contexts,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_allowed_mcp_servers",
|
|
fake_get_allowed_mcp_servers,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_id",
|
|
lambda server_id: stub_server if server_id == "server-1" else None,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"_get_tools_for_single_server",
|
|
fake_get_tools,
|
|
raising=False,
|
|
)
|
|
|
|
request = _build_request(path="/mcp-rest/tools/list", method="GET")
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await rest_endpoints.list_tool_rest_api(
|
|
request,
|
|
server_id="server-1",
|
|
user_api_key_dict=UserAPIKeyAuth(),
|
|
)
|
|
|
|
assert exc_info.value.status_code == upstream_status
|
|
assert exc_info.value.headers == {"www-authenticate": challenge}
|
|
|
|
async def test_single_server_upstream_fault_surfaces_truthful_status(self, monkeypatch):
|
|
"""A single-server listing whose upstream breaks (5xx, timeout, unreachable) must answer
|
|
with the truthful gateway status instead of masking the failure as an empty-success
|
|
{"tools": [], "error": null} body a caller cannot distinguish from a toolless server."""
|
|
from litellm.proxy.mcp_server.exceptions import (
|
|
MCPServerListError,
|
|
)
|
|
from litellm.proxy.mcp_server.faults.list_outcomes import (
|
|
ServerListFault,
|
|
)
|
|
|
|
class StubServer:
|
|
alias = "server-1"
|
|
server_name = "server-1"
|
|
name = "flaky"
|
|
allowed_tools = None
|
|
mcp_info = {"server_name": "flaky"}
|
|
available_on_public_internet = True
|
|
|
|
stub_server = StubServer()
|
|
|
|
async def fake_contexts(user_api_key_auth):
|
|
return [user_api_key_auth]
|
|
|
|
async def fake_get_allowed_mcp_servers(*args, **kwargs):
|
|
return ["server-1"]
|
|
|
|
async def fake_get_tools(*args, **kwargs):
|
|
raise MCPServerListError(ServerListFault(tag="upstream_error", status_code=503), "flaky")
|
|
|
|
monkeypatch.setattr(rest_endpoints, "build_effective_auth_contexts", fake_contexts, raising=False)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_allowed_mcp_servers",
|
|
fake_get_allowed_mcp_servers,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_id",
|
|
lambda server_id: stub_server if server_id == "server-1" else None,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(rest_endpoints, "_get_tools_for_single_server", fake_get_tools, raising=False)
|
|
|
|
request = _build_request(path="/mcp-rest/tools/list", method="GET")
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await rest_endpoints.list_tool_rest_api(
|
|
request,
|
|
server_id="server-1",
|
|
user_api_key_dict=UserAPIKeyAuth(),
|
|
)
|
|
|
|
assert exc_info.value.status_code == 502
|
|
assert exc_info.value.detail["error"] == "upstream_error"
|
|
assert "server-1" in exc_info.value.detail["message"]
|
|
assert "flaky" not in exc_info.value.detail["message"]
|
|
|
|
async def test_aggregate_list_absorbs_one_server_auth_failure(self, monkeypatch):
|
|
"""The multi-server aggregate listing degrades a server whose upstream
|
|
rejects auth to an empty contribution and still returns the healthy
|
|
server's tools with a 200, rather than surfacing a 401. The absorbed
|
|
server must still show up as a classified per-server outcome so a REST
|
|
caller can tell "needs upstream auth" apart from "has no tools"."""
|
|
from pydantic import TypeAdapter
|
|
|
|
from litellm.proxy.mcp_server.exceptions import (
|
|
MCPUpstreamAuthError,
|
|
)
|
|
|
|
class StubServer:
|
|
def __init__(self, name):
|
|
self.alias = name
|
|
self.server_name = name
|
|
self.name = name
|
|
self.allowed_tools = None
|
|
self.mcp_info = {"server_name": name}
|
|
self.available_on_public_internet = True
|
|
|
|
good = StubServer("good")
|
|
bad = StubServer("bad")
|
|
|
|
async def fake_contexts(user_api_key_auth):
|
|
return [user_api_key_auth]
|
|
|
|
async def fake_get_allowed_mcp_servers(*args, **kwargs):
|
|
return ["good", "bad"]
|
|
|
|
async def fake_get_tools(server, *args, **kwargs):
|
|
if server.server_name == "bad":
|
|
raise MCPUpstreamAuthError(
|
|
status_code=401,
|
|
www_authenticate='Bearer realm="x"',
|
|
server_name="bad",
|
|
)
|
|
return ["good-tool"]
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"build_effective_auth_contexts",
|
|
fake_contexts,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_allowed_mcp_servers",
|
|
fake_get_allowed_mcp_servers,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_id",
|
|
lambda server_id: {"good": good, "bad": bad}.get(server_id),
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"_get_tools_for_single_server",
|
|
fake_get_tools,
|
|
raising=False,
|
|
)
|
|
|
|
request = _build_request(path="/mcp-rest/tools/list", method="GET")
|
|
result = await rest_endpoints.list_tool_rest_api(
|
|
request,
|
|
server_id=None,
|
|
user_api_key_dict=UserAPIKeyAuth(),
|
|
)
|
|
|
|
assert result["tools"] == ["good-tool"]
|
|
assert result["error"] is None
|
|
wire_body = json.loads(TypeAdapter(dict).dump_json(result))
|
|
assert wire_body["server_outcomes"] == {
|
|
"good": {"status": "ok", "tool_count": 1},
|
|
"bad": {"status": "auth_required", "http_status": 401},
|
|
}
|
|
|
|
async def test_name_resolution_finds_server_by_uuid(self, monkeypatch):
|
|
"""When server_id is a name string, it should be resolved to its UUID
|
|
and used for the tools lookup when the UUID is in allowed_server_ids."""
|
|
from litellm.proxy.mcp_server.server import MCPServer
|
|
from litellm.types.mcp import MCPTransport
|
|
|
|
stub_server = MCPServer(
|
|
server_id="uuid-abc-123",
|
|
name="my-server",
|
|
transport=MCPTransport.sse,
|
|
)
|
|
stub_server.alias = "my-server"
|
|
stub_server.server_name = "my-server"
|
|
stub_server.available_on_public_internet = True
|
|
stub_server.allowed_tools = None
|
|
stub_server.mcp_info = {"server_name": "my-server"}
|
|
|
|
async def fake_contexts(user_api_key_auth):
|
|
return [user_api_key_auth]
|
|
|
|
# Allowed list contains the UUID, not the name
|
|
async def fake_get_allowed_mcp_servers(*args, **kwargs):
|
|
return ["uuid-abc-123"]
|
|
|
|
captured = {"called": False, "server_arg": None}
|
|
|
|
async def fake_get_tools(
|
|
server,
|
|
server_auth_header,
|
|
raw_headers=None,
|
|
user_api_key_auth=None,
|
|
extra_headers=None,
|
|
apply_tool_filters=True,
|
|
client_ip=None,
|
|
):
|
|
captured["called"] = True
|
|
captured["server_arg"] = server
|
|
return ["tool-x"]
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"build_effective_auth_contexts",
|
|
fake_contexts,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_allowed_mcp_servers",
|
|
fake_get_allowed_mcp_servers,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_name",
|
|
lambda name: stub_server if name == "my-server" else None,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_id",
|
|
lambda sid: stub_server if sid == "uuid-abc-123" else None,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"_get_tools_for_single_server",
|
|
fake_get_tools,
|
|
raising=False,
|
|
)
|
|
|
|
request = _build_request(path="/mcp-rest/tools/list", method="GET")
|
|
result = await rest_endpoints.list_tool_rest_api(
|
|
request,
|
|
server_id="my-server", # pass name, not UUID
|
|
user_api_key_dict=UserAPIKeyAuth(),
|
|
)
|
|
|
|
assert captured["called"] is True
|
|
assert captured["server_arg"] is stub_server
|
|
assert result["tools"] == ["tool-x"]
|
|
assert result["error"] is None
|
|
|
|
async def test_name_not_in_allowed_returns_access_denied(self, monkeypatch):
|
|
"""When name resolves to a server whose UUID is NOT in allowed_server_ids,
|
|
the result should be an access_denied error (not a crash or silent pass)."""
|
|
from litellm.proxy.mcp_server.server import MCPServer
|
|
from litellm.types.mcp import MCPTransport
|
|
|
|
stub_server = MCPServer(
|
|
server_id="uuid-xyz-999",
|
|
name="restricted-server",
|
|
transport=MCPTransport.sse,
|
|
)
|
|
stub_server.available_on_public_internet = True
|
|
|
|
async def fake_contexts(user_api_key_auth):
|
|
return [user_api_key_auth]
|
|
|
|
# No allowed servers for this key
|
|
async def fake_get_allowed_mcp_servers(*args, **kwargs):
|
|
return []
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"build_effective_auth_contexts",
|
|
fake_contexts,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_allowed_mcp_servers",
|
|
fake_get_allowed_mcp_servers,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_name",
|
|
lambda name: stub_server if name == "restricted-server" else None,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_id",
|
|
lambda sid: stub_server if sid == "uuid-xyz-999" else None,
|
|
raising=False,
|
|
)
|
|
|
|
request = _build_request(path="/mcp-rest/tools/list", method="GET")
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await rest_endpoints.list_tool_rest_api(
|
|
request,
|
|
server_id="restricted-server",
|
|
user_api_key_dict=UserAPIKeyAuth(),
|
|
)
|
|
|
|
assert exc_info.value.status_code == 403
|
|
assert exc_info.value.detail["error"] == "access_denied"
|
|
|
|
async def test_mcp_server_name_query_param_resolves_to_server(self, monkeypatch):
|
|
"""mcp_server_name is a name-based alias for server_id: it should
|
|
resolve to the matching server and scope the response to it."""
|
|
from litellm.proxy.mcp_server.server import MCPServer
|
|
from litellm.types.mcp import MCPTransport
|
|
|
|
stub_server = MCPServer(
|
|
server_id="uuid-abc-123",
|
|
name="my-server",
|
|
transport=MCPTransport.sse,
|
|
)
|
|
stub_server.alias = "my-server"
|
|
stub_server.server_name = "my-server"
|
|
stub_server.available_on_public_internet = True
|
|
stub_server.allowed_tools = None
|
|
stub_server.mcp_info = {"server_name": "my-server"}
|
|
|
|
async def fake_contexts(user_api_key_auth):
|
|
return [user_api_key_auth]
|
|
|
|
async def fake_get_allowed_mcp_servers(*args, **kwargs):
|
|
return ["uuid-abc-123"]
|
|
|
|
captured = {"called": False, "server_arg": None}
|
|
|
|
async def fake_get_tools(
|
|
server,
|
|
server_auth_header,
|
|
raw_headers=None,
|
|
user_api_key_auth=None,
|
|
extra_headers=None,
|
|
apply_tool_filters=True,
|
|
client_ip=None,
|
|
):
|
|
captured["called"] = True
|
|
captured["server_arg"] = server
|
|
return ["tool-x"]
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"build_effective_auth_contexts",
|
|
fake_contexts,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_allowed_mcp_servers",
|
|
fake_get_allowed_mcp_servers,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_name",
|
|
lambda name: stub_server if name == "my-server" else None,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_id",
|
|
lambda sid: stub_server if sid == "uuid-abc-123" else None,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"_get_tools_for_single_server",
|
|
fake_get_tools,
|
|
raising=False,
|
|
)
|
|
|
|
request = _build_request(path="/mcp-rest/tools/list", method="GET")
|
|
result = await rest_endpoints.list_tool_rest_api(
|
|
request,
|
|
server_id=None,
|
|
mcp_server_name="my-server",
|
|
user_api_key_dict=UserAPIKeyAuth(),
|
|
)
|
|
|
|
assert captured["called"] is True
|
|
assert captured["server_arg"] is stub_server
|
|
assert result["tools"] == ["tool-x"]
|
|
assert result["error"] is None
|
|
|
|
async def test_mcp_server_name_filter_uses_real_catalog_with_tool_search(self, monkeypatch):
|
|
from litellm.proxy._types import LiteLLM_ObjectPermissionTable
|
|
from litellm.proxy.mcp_server.server import MCPServer
|
|
from litellm.types.mcp import MCPTransport
|
|
|
|
stub_server = MCPServer(
|
|
server_id="uuid-search-123",
|
|
name="search-server",
|
|
transport=MCPTransport.sse,
|
|
)
|
|
stub_server.alias = "search-server"
|
|
stub_server.server_name = "search-server"
|
|
stub_server.available_on_public_internet = True
|
|
stub_server.allowed_tools = None
|
|
stub_server.mcp_info = {"server_name": "search-server"}
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
object_permission=LiteLLM_ObjectPermissionTable(
|
|
object_permission_id="search-scope",
|
|
mcp_tool_search_enabled=True,
|
|
mcp_servers=["uuid-search-123"],
|
|
)
|
|
)
|
|
|
|
async def fake_contexts(user_api_key_auth):
|
|
return [user_api_key_auth]
|
|
|
|
async def fake_get_allowed_mcp_servers(*args, **kwargs):
|
|
return ["uuid-search-123"]
|
|
|
|
async def fake_get_tools(
|
|
server,
|
|
server_auth_header,
|
|
raw_headers=None,
|
|
user_api_key_auth=None,
|
|
extra_headers=None,
|
|
apply_tool_filters=True,
|
|
client_ip=None,
|
|
):
|
|
return ["scoped-tool"]
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"build_effective_auth_contexts",
|
|
fake_contexts,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_allowed_mcp_servers",
|
|
fake_get_allowed_mcp_servers,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_name",
|
|
lambda name: stub_server if name == "search-server" else None,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_id",
|
|
lambda server_id: stub_server if server_id == "uuid-search-123" else None,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"_get_tools_for_single_server",
|
|
fake_get_tools,
|
|
raising=False,
|
|
)
|
|
|
|
request = _build_request(path="/mcp-rest/tools/list", method="GET")
|
|
result = await rest_endpoints.list_tool_rest_api(
|
|
request,
|
|
server_id=None,
|
|
mcp_server_name="search-server",
|
|
user_api_key_dict=user_api_key_dict,
|
|
)
|
|
|
|
assert result["tools"] == ["scoped-tool"]
|
|
assert result["error"] is None
|
|
|
|
async def test_toolset_name_query_param_scopes_to_toolset_servers(self, monkeypatch):
|
|
"""toolset_name should resolve the toolset, apply its scope to the
|
|
caller's UserAPIKeyAuth via _apply_toolset_scope, and only list tools
|
|
from servers the scoped auth is allowed to see."""
|
|
from litellm.proxy._types import LiteLLM_ObjectPermissionTable
|
|
|
|
scoped_auth = UserAPIKeyAuth(
|
|
object_permission=LiteLLM_ObjectPermissionTable(
|
|
object_permission_id="toolset-scope",
|
|
mcp_tool_search_enabled=True,
|
|
mcp_servers=["toolset-server-1"],
|
|
)
|
|
)
|
|
|
|
class StubToolset:
|
|
toolset_id = "toolset-1"
|
|
|
|
class StubServer:
|
|
alias = "toolset-server-1"
|
|
server_name = "toolset-server-1"
|
|
name = "toolset-server-1"
|
|
allowed_tools = None
|
|
mcp_info = {"server_name": "toolset-server-1"}
|
|
available_on_public_internet = True
|
|
|
|
stub_server = StubServer()
|
|
|
|
async def fake_get_toolset_by_name_cached(prisma_client, toolset_name):
|
|
assert toolset_name == "research_tools"
|
|
return StubToolset()
|
|
|
|
async def fake_apply_toolset_scope(user_api_key_auth, toolset_id):
|
|
assert toolset_id == "toolset-1"
|
|
return scoped_auth
|
|
|
|
async def fake_contexts(user_api_key_auth):
|
|
return [user_api_key_auth]
|
|
|
|
async def fake_get_allowed_mcp_servers(**kwargs):
|
|
assert kwargs["user_api_key_auth"] is scoped_auth
|
|
return ["toolset-server-1"]
|
|
|
|
async def fake_get_tools(server, server_auth_header, *args, **kwargs):
|
|
return ["toolset-tool-1"]
|
|
|
|
monkeypatch.setattr(
|
|
"litellm.proxy.utils.get_prisma_client_or_throw",
|
|
lambda *args, **kwargs: MagicMock(),
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_toolset_by_name_cached",
|
|
fake_get_toolset_by_name_cached,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"apply_toolset_scope",
|
|
fake_apply_toolset_scope,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"build_effective_auth_contexts",
|
|
fake_contexts,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_allowed_mcp_servers",
|
|
fake_get_allowed_mcp_servers,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_id",
|
|
lambda server_id: stub_server if server_id == "toolset-server-1" else None,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"_get_tools_for_single_server",
|
|
fake_get_tools,
|
|
raising=False,
|
|
)
|
|
|
|
request = _build_request(path="/mcp-rest/tools/list", method="GET")
|
|
result = await rest_endpoints.list_tool_rest_api(
|
|
request,
|
|
server_id=None,
|
|
toolset_name="research_tools",
|
|
user_api_key_dict=UserAPIKeyAuth(),
|
|
)
|
|
|
|
assert result["tools"] == ["toolset-tool-1"]
|
|
assert result["error"] is None
|
|
|
|
async def test_toolset_name_not_found_returns_error(self, monkeypatch):
|
|
async def fake_get_toolset_by_name_cached(prisma_client, toolset_name):
|
|
return None
|
|
|
|
monkeypatch.setattr(
|
|
"litellm.proxy.utils.get_prisma_client_or_throw",
|
|
lambda *args, **kwargs: MagicMock(),
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_toolset_by_name_cached",
|
|
fake_get_toolset_by_name_cached,
|
|
raising=False,
|
|
)
|
|
|
|
request = _build_request(path="/mcp-rest/tools/list", method="GET")
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await rest_endpoints.list_tool_rest_api(
|
|
request,
|
|
server_id=None,
|
|
toolset_name="does-not-exist",
|
|
user_api_key_dict=UserAPIKeyAuth(),
|
|
)
|
|
|
|
assert exc_info.value.status_code == 404
|
|
assert "does-not-exist" in str(exc_info.value.detail)
|
|
|
|
async def test_oauth2_user_token_injected_for_single_server(self, monkeypatch):
|
|
"""For a single-server OAuth2 request, _get_user_oauth_extra_headers is called
|
|
and the returned headers are forwarded to _get_tools_for_single_server."""
|
|
from litellm.proxy.mcp_server.server import MCPServer
|
|
from litellm.types.mcp import MCPTransport
|
|
|
|
stub_server = MCPServer(
|
|
server_id="oauth-server-id",
|
|
name="oauth-server",
|
|
transport=MCPTransport.sse,
|
|
)
|
|
stub_server.alias = "oauth-server"
|
|
stub_server.server_name = "oauth-server"
|
|
stub_server.available_on_public_internet = True
|
|
stub_server.allowed_tools = None
|
|
stub_server.mcp_info = {"server_name": "oauth-server"}
|
|
stub_server.auth_type = MCPAuth.oauth2
|
|
|
|
async def fake_contexts(user_api_key_auth):
|
|
return [user_api_key_auth]
|
|
|
|
async def fake_get_allowed_mcp_servers(*args, **kwargs):
|
|
return ["oauth-server-id"]
|
|
|
|
oauth_headers = {"Authorization": "Bearer user-oauth-token"}
|
|
|
|
async def fake_get_user_oauth_extra_headers(server, user_api_key_dict, prefetched_creds=None):
|
|
return oauth_headers
|
|
|
|
captured = {}
|
|
|
|
async def fake_get_tools(
|
|
server,
|
|
server_auth_header,
|
|
raw_headers=None,
|
|
user_api_key_auth=None,
|
|
extra_headers=None,
|
|
apply_tool_filters=True,
|
|
client_ip=None,
|
|
):
|
|
captured["server"] = server
|
|
captured["auth_header"] = server_auth_header
|
|
return ["oauth-tool"]
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"build_effective_auth_contexts",
|
|
fake_contexts,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_allowed_mcp_servers",
|
|
fake_get_allowed_mcp_servers,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_id",
|
|
lambda sid: stub_server if sid == "oauth-server-id" else None,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"_get_user_oauth_extra_headers",
|
|
fake_get_user_oauth_extra_headers,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"_get_tools_for_single_server",
|
|
fake_get_tools,
|
|
raising=False,
|
|
)
|
|
|
|
request = _build_request(path="/mcp-rest/tools/list", method="GET")
|
|
result = await rest_endpoints.list_tool_rest_api(
|
|
request,
|
|
server_id="oauth-server-id",
|
|
user_api_key_dict=UserAPIKeyAuth(user_id="user-123"),
|
|
)
|
|
|
|
assert result["tools"] == ["oauth-tool"]
|
|
assert result["error"] is None
|
|
|
|
|
|
class TestCallToolRestAPI:
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
async def test_rejects_disallowed_server(self, monkeypatch):
|
|
async def fake_contexts(user_api_key_auth):
|
|
return [user_api_key_auth]
|
|
|
|
async def fake_get_allowed_mcp_servers(*args, **kwargs):
|
|
return []
|
|
|
|
async def fake_add_litellm_data_to_request(**kwargs):
|
|
return kwargs.get("data", {})
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"build_effective_auth_contexts",
|
|
fake_contexts,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_allowed_mcp_servers",
|
|
fake_get_allowed_mcp_servers,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
"litellm.proxy.proxy_server.add_litellm_data_to_request",
|
|
fake_add_litellm_data_to_request,
|
|
raising=False,
|
|
)
|
|
|
|
mock_server = MagicMock()
|
|
mock_server.server_id = "server-1"
|
|
mock_server.name = "Example server"
|
|
|
|
def fake_get_mcp_server_by_id(server_id):
|
|
return mock_server if server_id == "server-1" else None
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_id",
|
|
fake_get_mcp_server_by_id,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_name",
|
|
lambda *args, **kwargs: None,
|
|
raising=False,
|
|
)
|
|
|
|
failure_log = AsyncMock()
|
|
execute_tool = AsyncMock()
|
|
monkeypatch.setattr(rest_endpoints, "_safe_fire_mcp_tool_call_failure_logging", failure_log)
|
|
monkeypatch.setattr(rest_endpoints, "execute_mcp_tool", execute_tool)
|
|
|
|
request_payload = {
|
|
"server_id": "server-1",
|
|
"name": "demo-tool",
|
|
"arguments": {"foo": "bar"},
|
|
}
|
|
request = _build_request(
|
|
path="/mcp-rest/tools/call",
|
|
method="POST",
|
|
json_body=request_payload,
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await rest_endpoints.call_tool_rest_api(
|
|
request,
|
|
user_api_key_dict=UserAPIKeyAuth(),
|
|
)
|
|
|
|
assert exc_info.value.status_code == 403
|
|
assert exc_info.value.detail["error"] == "access_denied"
|
|
assert "server server-1" in exc_info.value.detail["message"]
|
|
|
|
execute_tool.assert_not_awaited()
|
|
failure_log.assert_awaited_once()
|
|
logged_data = failure_log.await_args.args[4]
|
|
assert logged_data["model"] == "MCP: demo-tool"
|
|
assert logged_data["metadata"]["model_group"] == "MCP: demo-tool"
|
|
logging_obj = failure_log.await_args.args[0]
|
|
assert logging_obj.model_call_details["mcp_tool_call_metadata"] == {
|
|
"name": "demo-tool",
|
|
"mcp_server_name": "Example server",
|
|
}
|
|
|
|
@pytest.mark.parametrize(
|
|
("tool_name", "native_tool_name"),
|
|
(
|
|
("math_a-add", "add"),
|
|
("add", "add"),
|
|
("math_a-math_a-foo", "math_a-foo"),
|
|
),
|
|
)
|
|
async def test_executes_tool_when_allowed(self, monkeypatch, tool_name, native_tool_name):
|
|
import litellm
|
|
from litellm.proxy.mcp_server.auth.user_api_key_auth_mcp import MCPRequestHandler, MCPServerAccess
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
|
|
async def fake_contexts(user_api_key_auth):
|
|
return [user_api_key_auth]
|
|
|
|
async def fake_get_allowed_mcp_servers(*args, **kwargs):
|
|
return ["server-1"]
|
|
|
|
class StubServer:
|
|
server_id = "server-1"
|
|
alias = "math_a"
|
|
server_name = "math_a"
|
|
tool_name_to_display_name = None
|
|
name = "stub"
|
|
allowed_tools = None
|
|
mcp_info = {"server_name": "stub"}
|
|
available_on_public_internet = True
|
|
auth_type = None
|
|
|
|
stub_server = StubServer()
|
|
original_auth: Final = UserAPIKeyAuth(api_key="sk-personal", user_id="owner")
|
|
source_auth: Final = original_auth.model_copy(update={"team_id": "team-a", "org_id": "org-a"})
|
|
|
|
async def fake_attributing_source(
|
|
auth: UserAPIKeyAuth,
|
|
server_id: str,
|
|
tool_name: str,
|
|
grants: tuple[tuple[UserAPIKeyAuth, MCPServerAccess], ...] | None = None,
|
|
) -> UserAPIKeyAuth | None:
|
|
captured["attributed_tool_name"] = tool_name
|
|
return source_auth if server_id == "server-1" and tool_name == native_tool_name else None
|
|
|
|
async def fake_add_litellm_data_to_request(**kwargs):
|
|
return kwargs.get("data", {})
|
|
|
|
captured = {}
|
|
|
|
async def fake_execute_mcp_tool(**kwargs):
|
|
captured.update(kwargs)
|
|
captured["executed_tool_name"] = mcp_operations.resolve_native_tool_name_for_server(
|
|
kwargs["name"],
|
|
stub_server,
|
|
kwargs["allowed_mcp_servers"],
|
|
)
|
|
return _OK_TOOL_RESULT
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"build_effective_auth_contexts",
|
|
fake_contexts,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_allowed_mcp_servers",
|
|
fake_get_allowed_mcp_servers,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_id",
|
|
lambda server_id: stub_server if server_id == "server-1" else None,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
global_mcp_server_manager,
|
|
"get_mcp_server_from_tool_name",
|
|
lambda tool_name: stub_server if tool_name.startswith("math_a-") else None,
|
|
)
|
|
monkeypatch.setattr(litellm, "personal_key_model_access_from_teams", True)
|
|
monkeypatch.setattr(MCPRequestHandler, "personal_key_attributing_source", fake_attributing_source)
|
|
monkeypatch.setattr(
|
|
"litellm.proxy.proxy_server.add_litellm_data_to_request",
|
|
fake_add_litellm_data_to_request,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
"litellm.proxy.proxy_server.proxy_config",
|
|
{},
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"execute_mcp_tool",
|
|
fake_execute_mcp_tool,
|
|
raising=False,
|
|
)
|
|
fire_logging = AsyncMock(side_effect=RuntimeError("logging failed"))
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"_fire_mcp_tool_call_logging",
|
|
fire_logging,
|
|
raising=False,
|
|
)
|
|
|
|
request_payload = {
|
|
"server_id": "server-1",
|
|
"name": tool_name,
|
|
"arguments": {"foo": "bar"},
|
|
}
|
|
request = _build_request(
|
|
path="/mcp-rest/tools/call",
|
|
method="POST",
|
|
json_body=request_payload,
|
|
)
|
|
|
|
result = await rest_endpoints.call_tool_rest_api(
|
|
request,
|
|
user_api_key_dict=original_auth,
|
|
)
|
|
|
|
assert result == _OK_TOOL_RESULT
|
|
assert captured["name"] == tool_name
|
|
assert captured["executed_tool_name"] == native_tool_name
|
|
assert captured["attributed_tool_name"] == native_tool_name
|
|
assert captured["arguments"] == {"foo": "bar"}
|
|
assert captured["allowed_mcp_servers"] == [stub_server]
|
|
assert captured["oauth2_headers"] is None
|
|
assert captured["user_api_key_auth"].team_id is None
|
|
assert captured["user_api_key_auth"].org_id is None
|
|
assert captured["guardrail_auth"].team_id == "team-a"
|
|
assert captured["guardrail_auth"].org_id == "org-a"
|
|
assert fire_logging.await_args.kwargs["user_api_key_auth"] is captured["guardrail_auth"]
|
|
logging_obj = fire_logging.await_args.args[0]
|
|
assert logging_obj.model_call_details["litellm_params"]["metadata"]["user_api_key_team_id"] == "team-a"
|
|
fire_logging.assert_awaited_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rest_billing_uses_target_server_when_global_tool_map_is_empty(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
import litellm
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, LiteLLM_TeamTable, LiteLLM_UserTable
|
|
from litellm.proxy.auth import auth_checks
|
|
from litellm.proxy.mcp_server.mcp_server_manager import global_mcp_server_manager
|
|
|
|
target_server: Final = MCPServer(
|
|
server_id="server-b",
|
|
name="server-b",
|
|
server_name="server-b",
|
|
transport=MCPTransport.http,
|
|
)
|
|
team: Final = LiteLLM_TeamTable(
|
|
team_id="team-b",
|
|
object_permission_id="perm-team-b",
|
|
organization_id="org-team-b",
|
|
object_permission=LiteLLM_ObjectPermissionTable(
|
|
object_permission_id="perm-team-b",
|
|
mcp_servers=["server-b"],
|
|
mcp_tool_permissions={"server-b": ["search"]},
|
|
),
|
|
)
|
|
auth: Final = UserAPIKeyAuth(api_key="sk-personal", user_id="alice")
|
|
|
|
async def _get_team_object(team_id: str, **_: object) -> LiteLLM_TeamTable:
|
|
assert team_id == "team-b"
|
|
return team
|
|
|
|
monkeypatch.setattr(litellm, "personal_key_model_access_from_teams", True)
|
|
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
|
|
monkeypatch.setattr(proxy_server, "user_api_key_cache", MagicMock())
|
|
monkeypatch.setattr(proxy_server, "proxy_logging_obj", MagicMock())
|
|
monkeypatch.setattr(
|
|
auth_checks,
|
|
"get_user_object",
|
|
AsyncMock(return_value=LiteLLM_UserTable(user_id="alice", teams=["team-b"])),
|
|
)
|
|
monkeypatch.setattr(auth_checks, "get_team_object", _get_team_object)
|
|
monkeypatch.setattr(auth_checks, "get_team_membership", AsyncMock(return_value=None))
|
|
monkeypatch.setattr(auth_checks, "get_org_object", AsyncMock(return_value=None))
|
|
monkeypatch.setattr(auth_checks, "_check_team_member_budget", AsyncMock(return_value=None))
|
|
monkeypatch.setitem(global_mcp_server_manager.registry, target_server.server_id, target_server)
|
|
monkeypatch.setattr(global_mcp_server_manager, "tool_name_to_mcp_server_name_mapping", {})
|
|
|
|
resolved_server, billed = await rest_endpoints._rest_tool_call_server_and_auth(
|
|
auth,
|
|
(target_server,),
|
|
target_server.server_id,
|
|
"search",
|
|
)
|
|
|
|
assert resolved_server is target_server
|
|
assert billed.team_id == "team-b"
|
|
|
|
async def test_extract_mcp_headers_scrubs_admitted_caller_credential(self) -> None:
|
|
caller_key: Final = "sk-rest-caller-admission-key-123"
|
|
upstream_token: Final = "Bearer unrelated-upstream-token"
|
|
request: Final = _build_request(
|
|
headers={
|
|
"x-litellm-api-key": f"Bearer {caller_key}",
|
|
"authorization": f"Bearer {caller_key}",
|
|
"x-mcp-echo_srv-authorization": f"Bearer {caller_key}",
|
|
"x-mcp-upstream-authorization": upstream_token,
|
|
}
|
|
)
|
|
|
|
extracted_headers: Final = rest_endpoints._extract_mcp_headers_from_request(
|
|
request,
|
|
UserAPIKeyAuth(api_key="stored-key-hash"),
|
|
)
|
|
|
|
assert extracted_headers == (
|
|
None,
|
|
{"upstream": {"Authorization": upstream_token}},
|
|
{
|
|
"x-litellm-api-key": f"Bearer {caller_key}",
|
|
"x-mcp-upstream-authorization": upstream_token,
|
|
},
|
|
None,
|
|
)
|
|
|
|
async def test_extract_mcp_headers_preserves_caller_credential_without_authenticated_key(self) -> None:
|
|
caller_key: Final = "sk-rest-caller-admission-key-123"
|
|
caller_authorization: Final = f"Bearer {caller_key}"
|
|
upstream_token: Final = "Bearer unrelated-upstream-token"
|
|
request: Final = _build_request(
|
|
headers={
|
|
"x-litellm-api-key": caller_authorization,
|
|
"authorization": caller_authorization,
|
|
"x-mcp-echo_srv-authorization": caller_authorization,
|
|
"x-mcp-upstream-authorization": upstream_token,
|
|
}
|
|
)
|
|
|
|
extracted_headers: Final = rest_endpoints._extract_mcp_headers_from_request(
|
|
request,
|
|
UserAPIKeyAuth(),
|
|
)
|
|
|
|
assert extracted_headers == (
|
|
None,
|
|
{
|
|
"echo_srv": {"Authorization": caller_authorization},
|
|
"upstream": {"Authorization": upstream_token},
|
|
},
|
|
{
|
|
"x-litellm-api-key": caller_authorization,
|
|
"authorization": caller_authorization,
|
|
"x-mcp-echo_srv-authorization": caller_authorization,
|
|
"x-mcp-upstream-authorization": upstream_token,
|
|
},
|
|
{"Authorization": caller_authorization},
|
|
)
|
|
|
|
@pytest.mark.parametrize(
|
|
("structured", "expected_structured", "expected_texts"),
|
|
[
|
|
({"a": 1}, {"a": 1}, ['{"a": 1}']),
|
|
([1, 2], None, ['{"a": 1}', "[1, 2]"]),
|
|
],
|
|
)
|
|
async def test_rest_keeps_its_serialization_shape_with_legacy_structured_admission(
|
|
self, monkeypatch, structured, expected_structured, expected_texts
|
|
):
|
|
"""REST has no negotiated revision, so it admits object structuredContent only and downgrades
|
|
anything else losslessly, while the response keeps the SDK model shape (resultType included)
|
|
rather than being run through the MCP legacy wire serializer."""
|
|
|
|
async def fake_contexts(user_api_key_auth):
|
|
return [user_api_key_auth]
|
|
|
|
async def fake_get_allowed_mcp_servers(*args, **kwargs):
|
|
return ["server-1"]
|
|
|
|
class StubServer:
|
|
server_id = "server-1"
|
|
alias = "server-1"
|
|
server_name = "server-1"
|
|
tool_name_to_display_name = None
|
|
name = "stub"
|
|
allowed_tools = None
|
|
mcp_info = {"server_name": "stub"}
|
|
available_on_public_internet = True
|
|
auth_type = None
|
|
|
|
stub_server = StubServer()
|
|
|
|
async def fake_add_litellm_data_to_request(**kwargs):
|
|
return kwargs.get("data", {})
|
|
|
|
async def fake_execute_mcp_tool(**kwargs):
|
|
return CallToolResult(
|
|
content=[TextContent(type="text", text='{"a": 1}')],
|
|
structuredContent=structured,
|
|
isError=False,
|
|
)
|
|
|
|
async def fake_fire_logging(logging_obj, result, start_time, end_time, **kwargs):
|
|
return result
|
|
|
|
monkeypatch.setattr(rest_endpoints, "build_effective_auth_contexts", fake_contexts, raising=False)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager, "get_allowed_mcp_servers", fake_get_allowed_mcp_servers
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_id",
|
|
lambda server_id: stub_server if server_id == "server-1" else None,
|
|
)
|
|
monkeypatch.setattr(
|
|
"litellm.proxy.proxy_server.add_litellm_data_to_request", fake_add_litellm_data_to_request, raising=False
|
|
)
|
|
monkeypatch.setattr("litellm.proxy.proxy_server.proxy_config", {}, raising=False)
|
|
monkeypatch.setattr(rest_endpoints, "execute_mcp_tool", fake_execute_mcp_tool, raising=False)
|
|
monkeypatch.setattr(rest_endpoints, "_fire_mcp_tool_call_logging", fake_fire_logging, raising=False)
|
|
|
|
request = _build_request(
|
|
path="/mcp-rest/tools/call",
|
|
method="POST",
|
|
json_body={"server_id": "server-1", "name": "demo-tool", "arguments": {}},
|
|
)
|
|
|
|
result = await rest_endpoints.call_tool_rest_api(request, user_api_key_dict=UserAPIKeyAuth())
|
|
|
|
assert isinstance(result, CallToolResult)
|
|
dumped = result.model_dump(by_alias=True, mode="json", exclude_none=True)
|
|
assert dumped.get("structuredContent") == expected_structured
|
|
assert [block["text"] for block in dumped["content"]] == expected_texts
|
|
assert dumped["resultType"] == "complete"
|
|
assert dumped["isError"] is False
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("auth_type", "per_user_oauth", "expected"),
|
|
[
|
|
("oauth_delegate", None, {"Authorization": "Bearer user-subject-token"}),
|
|
(
|
|
"oauth_delegate",
|
|
{"Authorization": "Bearer per-user-oauth-token"},
|
|
{"Authorization": "Bearer per-user-oauth-token"},
|
|
),
|
|
("oauth2", None, None),
|
|
],
|
|
)
|
|
async def test_forwards_callers_bearer_as_oauth2_headers(self, monkeypatch, auth_type, per_user_oauth, expected):
|
|
"""A distinct caller Authorization rides oauth2_headers to execute_mcp_tool only for
|
|
client-forwarded-token servers, with a per-user OAuth token still taking precedence.
|
|
A gateway-managed oauth2 server never sees the caller's bearer."""
|
|
|
|
async def fake_get_allowed_mcp_servers(*args, **kwargs):
|
|
return ["server-1"]
|
|
|
|
class StubServer:
|
|
server_id = "server-1"
|
|
alias = "server-1"
|
|
server_name = "server-1"
|
|
tool_name_to_display_name = None
|
|
name = "stub"
|
|
allowed_tools = None
|
|
mcp_info = {"server_name": "stub"}
|
|
available_on_public_internet = True
|
|
|
|
stub_server = StubServer()
|
|
stub_server.auth_type = auth_type
|
|
|
|
async def fake_add_litellm_data_to_request(**kwargs):
|
|
return kwargs.get("data", {})
|
|
|
|
async def fake_get_user_oauth_extra_headers(server, user_api_key_dict, prefetched_creds=None):
|
|
return per_user_oauth
|
|
|
|
captured = {}
|
|
|
|
async def fake_execute_mcp_tool(**kwargs):
|
|
captured.update(kwargs)
|
|
return _OK_TOOL_RESULT
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager, "get_allowed_mcp_servers", fake_get_allowed_mcp_servers
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_id",
|
|
lambda server_id: stub_server if server_id == "server-1" else None,
|
|
)
|
|
monkeypatch.setattr("litellm.proxy.proxy_server.add_litellm_data_to_request", fake_add_litellm_data_to_request)
|
|
monkeypatch.setattr("litellm.proxy.proxy_server.proxy_config", {}, raising=False)
|
|
monkeypatch.setattr(rest_endpoints, "_get_user_oauth_extra_headers", fake_get_user_oauth_extra_headers)
|
|
monkeypatch.setattr(rest_endpoints, "execute_mcp_tool", fake_execute_mcp_tool)
|
|
monkeypatch.setattr(
|
|
rest_endpoints, "_fire_mcp_tool_call_logging", AsyncMock(side_effect=RuntimeError("logging failed"))
|
|
)
|
|
|
|
request = _build_request(
|
|
{"x-litellm-api-key": "sk-admission-key", "authorization": "Bearer user-subject-token"},
|
|
path="/mcp-rest/tools/call",
|
|
method="POST",
|
|
json_body={"server_id": "server-1", "name": "demo-tool", "arguments": {}},
|
|
)
|
|
|
|
result = await rest_endpoints.call_tool_rest_api(request, user_api_key_dict=UserAPIKeyAuth())
|
|
|
|
assert result == _OK_TOOL_RESULT
|
|
assert captured["oauth2_headers"] == expected
|
|
assert captured["raw_headers"]["authorization"] == "Bearer user-subject-token"
|
|
|
|
async def test_returns_guardrail_rewritten_tool_result(self, monkeypatch):
|
|
"""A post_mcp_call guardrail rewrite of the tool result must reach the REST caller,
|
|
not the raw result the upstream server returned."""
|
|
|
|
async def fake_contexts(user_api_key_auth):
|
|
return [user_api_key_auth]
|
|
|
|
async def fake_get_allowed_mcp_servers(*args, **kwargs):
|
|
return ["server-1"]
|
|
|
|
class StubServer:
|
|
server_id = "server-1"
|
|
alias = "server-1"
|
|
server_name = "server-1"
|
|
tool_name_to_display_name = None
|
|
name = "stub"
|
|
allowed_tools = None
|
|
mcp_info = {"server_name": "stub"}
|
|
available_on_public_internet = True
|
|
auth_type = None
|
|
|
|
stub_server = StubServer()
|
|
|
|
async def fake_add_litellm_data_to_request(**kwargs):
|
|
return kwargs.get("data", {})
|
|
|
|
async def fake_execute_mcp_tool(**kwargs):
|
|
return CallToolResult(content=[TextContent(type="text", text="jane@example.com")], is_error=False)
|
|
|
|
monkeypatch.setattr(rest_endpoints, "build_effective_auth_contexts", fake_contexts, raising=False)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_allowed_mcp_servers",
|
|
fake_get_allowed_mcp_servers,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_id",
|
|
lambda server_id: stub_server if server_id == "server-1" else None,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
"litellm.proxy.proxy_server.add_litellm_data_to_request",
|
|
fake_add_litellm_data_to_request,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr("litellm.proxy.proxy_server.proxy_config", {}, raising=False)
|
|
monkeypatch.setattr(rest_endpoints, "execute_mcp_tool", fake_execute_mcp_tool, raising=False)
|
|
masked_result = CallToolResult(content=[TextContent(type="text", text="<EMAIL_ADDRESS>")], is_error=False)
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"_fire_mcp_tool_call_logging",
|
|
AsyncMock(return_value=masked_result),
|
|
raising=False,
|
|
)
|
|
|
|
request = _build_request(
|
|
path="/mcp-rest/tools/call",
|
|
method="POST",
|
|
json_body={"server_id": "server-1", "name": "demo-tool", "arguments": {}},
|
|
)
|
|
|
|
result = await rest_endpoints.call_tool_rest_api(request, user_api_key_dict=UserAPIKeyAuth())
|
|
|
|
assert result == masked_result
|
|
|
|
async def test_success_logging_start_time_excludes_pre_call_processing(self, monkeypatch):
|
|
"""Pre-call hook latency (guardrails, header resolution) must not inflate the tool call's
|
|
logged duration on success."""
|
|
from litellm.proxy import proxy_server
|
|
|
|
async def fake_contexts(user_api_key_auth):
|
|
return [user_api_key_auth]
|
|
|
|
async def fake_get_allowed_mcp_servers(*args, **kwargs):
|
|
return ["server-1"]
|
|
|
|
class StubServer:
|
|
server_id = "server-1"
|
|
alias = "server-1"
|
|
server_name = "server-1"
|
|
tool_name_to_display_name = None
|
|
name = "stub"
|
|
allowed_tools = None
|
|
mcp_info = {"server_name": "stub"}
|
|
available_on_public_internet = True
|
|
auth_type = None
|
|
|
|
stub_server = StubServer()
|
|
|
|
async def fake_add_litellm_data_to_request(**kwargs):
|
|
return kwargs.get("data", {})
|
|
|
|
pre_call_finished_at = {}
|
|
|
|
async def slow_pre_call_hook(user_api_key_dict, data, call_type, skip_guardrails=False):
|
|
await asyncio.sleep(0.05)
|
|
pre_call_finished_at["value"] = datetime.now()
|
|
return data
|
|
|
|
captured = {}
|
|
|
|
async def fake_execute_mcp_tool(**kwargs):
|
|
captured.update(kwargs)
|
|
return _OK_TOOL_RESULT
|
|
|
|
fire_logging = AsyncMock(return_value=_OK_TOOL_RESULT)
|
|
monkeypatch.setattr(rest_endpoints, "build_effective_auth_contexts", fake_contexts, raising=False)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_allowed_mcp_servers",
|
|
fake_get_allowed_mcp_servers,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_id",
|
|
lambda server_id: stub_server if server_id == "server-1" else None,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
proxy_server, "add_litellm_data_to_request", fake_add_litellm_data_to_request, raising=False
|
|
)
|
|
monkeypatch.setattr(proxy_server, "proxy_config", {}, raising=False)
|
|
monkeypatch.setattr(proxy_server.proxy_logging_obj, "pre_call_hook", slow_pre_call_hook)
|
|
monkeypatch.setattr(rest_endpoints, "execute_mcp_tool", fake_execute_mcp_tool, raising=False)
|
|
monkeypatch.setattr(rest_endpoints, "_fire_mcp_tool_call_logging", fire_logging, raising=False)
|
|
|
|
request = _build_request(
|
|
path="/mcp-rest/tools/call",
|
|
method="POST",
|
|
json_body={"server_id": "server-1", "name": "demo-tool", "arguments": {}},
|
|
)
|
|
|
|
await rest_endpoints.call_tool_rest_api(request, user_api_key_dict=UserAPIKeyAuth())
|
|
|
|
logged_start_time = fire_logging.await_args.args[2]
|
|
assert captured["start_time"] >= pre_call_finished_at["value"]
|
|
assert logged_start_time == captured["start_time"]
|
|
|
|
async def test_success_logging_guardrail_rejection_propagates(self, monkeypatch):
|
|
"""A guardrail rejecting the tool result must not be swallowed as a logging failure,
|
|
otherwise the unguarded result would still be returned to the caller."""
|
|
from litellm.exceptions import BlockedPiiEntityError
|
|
|
|
fire_logging = AsyncMock(
|
|
side_effect=BlockedPiiEntityError(entity_type="EMAIL_ADDRESS", guardrail_name="presidio-mcp")
|
|
)
|
|
monkeypatch.setattr(rest_endpoints, "_fire_mcp_tool_call_logging", fire_logging, raising=False)
|
|
|
|
with pytest.raises(BlockedPiiEntityError):
|
|
await rest_endpoints._safe_fire_mcp_tool_call_logging(
|
|
object(), {"result": "ok"}, datetime.now(), datetime.now()
|
|
)
|
|
|
|
fire_logging.assert_awaited_once()
|
|
|
|
@pytest.mark.parametrize("upstream_status", [401, 403])
|
|
async def test_call_tool_rest_relays_upstream_auth_failure(self, monkeypatch, upstream_status):
|
|
"""A pass-through call that hits an upstream 401/403 (surfaced by the manager as
|
|
MCPUpstreamAuthError) must reach the REST caller as that status with the upstream
|
|
WWW-Authenticate preserved, so an MCP client can run the upstream OAuth flow, instead of the
|
|
generic 500 the catch-all would otherwise produce."""
|
|
from litellm.proxy.mcp_server.exceptions import MCPUpstreamAuthError
|
|
|
|
async def fake_contexts(user_api_key_auth):
|
|
return [user_api_key_auth]
|
|
|
|
async def fake_get_allowed_mcp_servers(*args, **kwargs):
|
|
return ["server-1"]
|
|
|
|
class StubServer:
|
|
server_id = "server-1"
|
|
alias = "server-1"
|
|
server_name = "server-1"
|
|
tool_name_to_display_name = None
|
|
name = "stub"
|
|
allowed_tools = None
|
|
mcp_info = {"server_name": "stub"}
|
|
available_on_public_internet = True
|
|
auth_type = None
|
|
|
|
stub_server = StubServer()
|
|
|
|
async def fake_add_litellm_data_to_request(**kwargs):
|
|
return kwargs.get("data", {})
|
|
|
|
challenge = 'Bearer resource_metadata="https://gw.example.com/.well-known/oauth-protected-resource/mcp/stub"'
|
|
|
|
async def fake_execute_mcp_tool(**kwargs):
|
|
raise MCPUpstreamAuthError(
|
|
status_code=upstream_status,
|
|
www_authenticate=challenge,
|
|
server_name="stub",
|
|
)
|
|
|
|
monkeypatch.setattr(rest_endpoints, "build_effective_auth_contexts", fake_contexts, raising=False)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_allowed_mcp_servers",
|
|
fake_get_allowed_mcp_servers,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_id",
|
|
lambda server_id: stub_server if server_id == "server-1" else None,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
"litellm.proxy.proxy_server.add_litellm_data_to_request",
|
|
fake_add_litellm_data_to_request,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr("litellm.proxy.proxy_server.proxy_config", {}, raising=False)
|
|
monkeypatch.setattr(rest_endpoints, "execute_mcp_tool", fake_execute_mcp_tool, raising=False)
|
|
|
|
mock_logger = MagicMock()
|
|
monkeypatch.setattr(rest_endpoints, "verbose_logger", mock_logger, raising=False)
|
|
|
|
request_payload = {
|
|
"server_id": "server-1",
|
|
"name": "demo-tool",
|
|
"arguments": {"foo": "bar"},
|
|
}
|
|
request = _build_request(
|
|
path="/mcp-rest/tools/call",
|
|
method="POST",
|
|
json_body=request_payload,
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await rest_endpoints.call_tool_rest_api(
|
|
request,
|
|
user_api_key_dict=UserAPIKeyAuth(),
|
|
)
|
|
|
|
assert exc_info.value.status_code == upstream_status
|
|
assert exc_info.value.headers is not None
|
|
assert exc_info.value.headers.get("www-authenticate") == challenge
|
|
# The expected caller-must-reauth signal is logged once, at info, and never at error, so
|
|
# error-rate alerts do not fire on normal pass-through re-authentication.
|
|
error_messages = [_rendered_log_message(c) for c in mock_logger.error.call_args_list if c.args]
|
|
assert not any("MCP tool call" in m for m in error_messages)
|
|
info_messages = [_rendered_log_message(c) for c in mock_logger.info.call_args_list if c.args]
|
|
assert sum(str(upstream_status) in m for m in info_messages) == 1
|
|
|
|
async def test_local_permission_denial_keeps_error_level_logging(self, monkeypatch):
|
|
"""Only the relayed upstream 401 may be demoted to info; a locally generated HTTPException 403
|
|
(tool permission, server access, IP filtering) raised inside the call must stay at error level
|
|
so an authenticated user probing restrictions keeps full monitoring visibility, and must be
|
|
re-raised unchanged (not converted to a re-auth relay)."""
|
|
|
|
async def fake_contexts(user_api_key_auth):
|
|
return [user_api_key_auth]
|
|
|
|
async def fake_get_allowed_mcp_servers(*args, **kwargs):
|
|
return ["server-1"]
|
|
|
|
class StubServer:
|
|
server_id = "server-1"
|
|
alias = "server-1"
|
|
server_name = "server-1"
|
|
tool_name_to_display_name = None
|
|
name = "stub"
|
|
allowed_tools = None
|
|
mcp_info = {"server_name": "stub"}
|
|
available_on_public_internet = True
|
|
auth_type = None
|
|
|
|
async def fake_add_litellm_data_to_request(**kwargs):
|
|
return kwargs.get("data", {})
|
|
|
|
async def fake_execute_mcp_tool(**kwargs):
|
|
raise HTTPException(status_code=403, detail="tool not allowed for key")
|
|
|
|
monkeypatch.setattr(rest_endpoints, "build_effective_auth_contexts", fake_contexts, raising=False)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_allowed_mcp_servers",
|
|
fake_get_allowed_mcp_servers,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_id",
|
|
lambda server_id: StubServer() if server_id == "server-1" else None,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
"litellm.proxy.proxy_server.add_litellm_data_to_request", fake_add_litellm_data_to_request, raising=False
|
|
)
|
|
monkeypatch.setattr("litellm.proxy.proxy_server.proxy_config", {}, raising=False)
|
|
monkeypatch.setattr(rest_endpoints, "execute_mcp_tool", fake_execute_mcp_tool, raising=False)
|
|
mock_logger = MagicMock()
|
|
monkeypatch.setattr(rest_endpoints, "verbose_logger", mock_logger, raising=False)
|
|
|
|
request = _build_request(
|
|
path="/mcp-rest/tools/call",
|
|
method="POST",
|
|
json_body={"server_id": "server-1", "name": "demo-tool", "arguments": {}},
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await rest_endpoints.call_tool_rest_api(request, user_api_key_dict=UserAPIKeyAuth())
|
|
|
|
assert exc_info.value.status_code == 403
|
|
error_messages = [_rendered_log_message(c) for c in mock_logger.error.call_args_list if c.args]
|
|
assert any("HTTPException in MCP tool call" in m for m in error_messages)
|
|
info_messages = [_rendered_log_message(c) for c in mock_logger.info.call_args_list if c.args]
|
|
assert not any("relaying upstream" in m for m in info_messages)
|
|
|
|
@pytest.mark.parametrize("raise_site", ["pre_call_hook", "execute_mcp_tool"])
|
|
@pytest.mark.parametrize("custom_code", [False, True])
|
|
async def test_guardrail_block_runs_failure_logging_before_http_translation(
|
|
self, monkeypatch, raise_site, custom_code
|
|
):
|
|
"""A pre_mcp_call guardrail block, whether raised by the pre-call hook or from inside
|
|
execute_mcp_tool, must reach proxy_logging_obj.post_call_failure_hook (the only path that
|
|
writes the failure spend-log row) with the logging object's failure payload already built,
|
|
and the REST caller must still get the same 400 it got before."""
|
|
from litellm.proxy import proxy_server
|
|
|
|
async def fake_contexts(user_api_key_auth):
|
|
return [user_api_key_auth]
|
|
|
|
async def fake_get_allowed_mcp_servers(*args, **kwargs):
|
|
return ["server-1"]
|
|
|
|
class StubServer:
|
|
server_id = "server-1"
|
|
alias = "server-1"
|
|
server_name = "server-1"
|
|
tool_name_to_display_name = None
|
|
name = "stub"
|
|
allowed_tools = None
|
|
mcp_info = {"server_name": "stub"}
|
|
available_on_public_internet = True
|
|
auth_type = None
|
|
|
|
async def fake_add_litellm_data_to_request(**kwargs):
|
|
return kwargs.get("data", {})
|
|
|
|
guardrail_error = HTTPException(
|
|
status_code=400,
|
|
detail={"error": "Content blocked: keyword 'confidential' detected", "keyword": "confidential"},
|
|
)
|
|
|
|
if custom_code:
|
|
guardrail_error = rest_endpoints.ModifyResponseException(
|
|
message="Content blocked", model="mcp-tool-call", request_data={}, guardrail_name="block-all"
|
|
)
|
|
|
|
async def passthrough_pre_call_hook(user_api_key_dict, data, call_type, skip_guardrails=False):
|
|
return data
|
|
|
|
async def blocking_pre_call_hook(user_api_key_dict, data, call_type, skip_guardrails=False):
|
|
raise guardrail_error
|
|
|
|
async def fake_execute_mcp_tool(**kwargs):
|
|
raise guardrail_error
|
|
|
|
async def passthrough_execute_mcp_tool(**kwargs):
|
|
return []
|
|
|
|
monkeypatch.setattr(rest_endpoints, "build_effective_auth_contexts", fake_contexts, raising=False)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_allowed_mcp_servers",
|
|
fake_get_allowed_mcp_servers,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_id",
|
|
lambda server_id: StubServer() if server_id == "server-1" else None,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
proxy_server, "add_litellm_data_to_request", fake_add_litellm_data_to_request, raising=False
|
|
)
|
|
monkeypatch.setattr(proxy_server, "proxy_config", {}, raising=False)
|
|
monkeypatch.setattr(
|
|
proxy_server.proxy_logging_obj,
|
|
"pre_call_hook",
|
|
blocking_pre_call_hook if raise_site == "pre_call_hook" else passthrough_pre_call_hook,
|
|
)
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"execute_mcp_tool",
|
|
fake_execute_mcp_tool if raise_site == "execute_mcp_tool" else passthrough_execute_mcp_tool,
|
|
raising=False,
|
|
)
|
|
post_call_failure_hook = AsyncMock(return_value=None)
|
|
monkeypatch.setattr(proxy_server.proxy_logging_obj, "post_call_failure_hook", post_call_failure_hook)
|
|
|
|
user_api_key_dict = UserAPIKeyAuth(api_key="hashed-key", request_route="/mcp-rest/tools/call")
|
|
request = _build_request(
|
|
headers={"x-mcp-deepwiki-authorization": "Bearer upstream-secret"},
|
|
path="/mcp-rest/tools/call",
|
|
method="POST",
|
|
json_body={"server_id": "server-1", "name": "demo-tool", "arguments": {"q": "confidential"}},
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await rest_endpoints.call_tool_rest_api(request, user_api_key_dict=user_api_key_dict)
|
|
|
|
assert exc_info.value.status_code == 400
|
|
if custom_code:
|
|
assert exc_info.value.detail == {
|
|
"error": "guardrail_violation",
|
|
"message": "Content blocked",
|
|
"guardrail_name": "block-all",
|
|
}
|
|
else:
|
|
assert exc_info.value is guardrail_error
|
|
|
|
post_call_failure_hook.assert_awaited_once()
|
|
hook_kwargs = post_call_failure_hook.await_args.kwargs
|
|
assert hook_kwargs["original_exception"] is guardrail_error
|
|
assert hook_kwargs["user_api_key_dict"] is user_api_key_dict
|
|
assert hook_kwargs["route"] == "/mcp/call_tool"
|
|
request_data = hook_kwargs["request_data"]
|
|
assert "raw_headers" not in request_data
|
|
assert "mcp_server_auth_headers" not in request_data
|
|
standard_logging_object = request_data["litellm_logging_obj"].model_call_details["standard_logging_object"]
|
|
assert standard_logging_object["status"] == "failure"
|
|
assert standard_logging_object["error_str"] == str(guardrail_error)
|
|
|
|
async def test_failure_logging_error_does_not_replace_guardrail_error(self, monkeypatch):
|
|
from litellm.proxy import proxy_server
|
|
|
|
guardrail_error = HTTPException(status_code=400, detail={"error": "Content blocked"})
|
|
server = MagicMock()
|
|
server.server_id = "server-1"
|
|
server.alias = "server-1"
|
|
server.server_name = "server-1"
|
|
server.short_prefix = None
|
|
server.tool_name_to_display_name = None
|
|
server.name = "stub"
|
|
|
|
async def fake_add_litellm_data_to_request(**kwargs):
|
|
return kwargs.get("data", {})
|
|
|
|
failure_logging = AsyncMock(side_effect=RuntimeError("spend log db down"))
|
|
execute_tool = AsyncMock(side_effect=guardrail_error)
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"_resolve_allowed_mcp_servers_with_ip_filter",
|
|
AsyncMock(return_value=([server], "server-1")),
|
|
)
|
|
monkeypatch.setattr(rest_endpoints, "execute_mcp_tool", execute_tool)
|
|
monkeypatch.setattr(
|
|
proxy_server, "add_litellm_data_to_request", fake_add_litellm_data_to_request, raising=False
|
|
)
|
|
monkeypatch.setattr(proxy_server, "proxy_config", {}, raising=False)
|
|
monkeypatch.setattr(rest_endpoints, "fire_mcp_tool_call_failure_logging", failure_logging, raising=False)
|
|
|
|
request = _build_request(
|
|
path="/mcp-rest/tools/call",
|
|
method="POST",
|
|
json_body={"server_id": "server-1", "name": "demo-tool", "arguments": {"q": "confidential"}},
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await rest_endpoints.call_tool_rest_api(
|
|
request, user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key", request_route="/mcp-rest/tools/call")
|
|
)
|
|
|
|
assert exc_info.value is guardrail_error
|
|
execute_tool.assert_awaited_once()
|
|
failure_logging.assert_awaited_once()
|
|
|
|
async def test_success_logging_cancellation_propagates(self, monkeypatch):
|
|
fire_logging = AsyncMock(side_effect=asyncio.CancelledError())
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"_fire_mcp_tool_call_logging",
|
|
fire_logging,
|
|
raising=False,
|
|
)
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await rest_endpoints._safe_fire_mcp_tool_call_logging(
|
|
object(), {"result": "ok"}, datetime.now(), datetime.now()
|
|
)
|
|
|
|
fire_logging.assert_awaited_once()
|
|
|
|
@pytest.mark.parametrize("upstream_status", [401, 403])
|
|
async def test_virtual_mcp_tool_call_relays_upstream_auth_failure(self, monkeypatch, upstream_status):
|
|
"""The virtual mcp_tool_call REST branch reaches execute_mcp_tool via handle_mcp_tool_call
|
|
without the direct branch's relay wrapper, so an MCPUpstreamAuthError from it must be relayed
|
|
by the endpoint-level handler (a real 401/403 + WWW-Authenticate) rather than falling through
|
|
the catch-all into a generic 500."""
|
|
import litellm.proxy.mcp_server.tool_search as tool_search_mod
|
|
from litellm.proxy._types import LiteLLM_ObjectPermissionTable
|
|
from litellm.proxy.mcp_server.exceptions import MCPUpstreamAuthError
|
|
|
|
challenge = 'Bearer resource_metadata="https://gw.example.com/.well-known/oauth-protected-resource"'
|
|
|
|
async def fake_contexts(user_api_key_auth):
|
|
return [user_api_key_auth]
|
|
|
|
async def fake_handle_mcp_tool_call(**kwargs):
|
|
raise MCPUpstreamAuthError(status_code=upstream_status, www_authenticate=challenge, server_name="stub")
|
|
|
|
class _FakePreCall:
|
|
def __init__(self, data):
|
|
self.data = data
|
|
|
|
async def common_processing_pre_call_logic(self, **kwargs):
|
|
return self.data, MagicMock()
|
|
|
|
monkeypatch.setattr(rest_endpoints, "build_effective_auth_contexts", fake_contexts, raising=False)
|
|
monkeypatch.setattr(tool_search_mod, "handle_mcp_tool_call", fake_handle_mcp_tool_call, raising=False)
|
|
monkeypatch.setattr(
|
|
"litellm.proxy.common_request_processing.ProxyBaseLLMRequestProcessing",
|
|
_FakePreCall,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr("litellm.proxy.proxy_server.proxy_config", {}, raising=False)
|
|
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {}, raising=False)
|
|
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
object_permission=LiteLLM_ObjectPermissionTable(
|
|
object_permission_id="search-scope",
|
|
mcp_tool_search_enabled=True,
|
|
)
|
|
)
|
|
request = _build_request(
|
|
path="/mcp-rest/tools/call",
|
|
method="POST",
|
|
json_body={"name": "mcp_tool_call", "arguments": {"tool_name": "x", "arguments": {}}},
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await rest_endpoints.call_tool_rest_api(request, user_api_key_dict=user_api_key_dict)
|
|
|
|
assert exc_info.value.status_code == upstream_status
|
|
assert exc_info.value.headers is not None
|
|
assert exc_info.value.headers.get("www-authenticate") == challenge
|
|
|
|
async def test_virtual_mcp_tool_call_guardrail_block_runs_failure_logging(self, monkeypatch):
|
|
"""A pre_mcp_call guardrail block on the virtual mcp_tool_call branch must write a failure
|
|
spend log, same as the direct tool call branch, and still raise the original error."""
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy._types import LiteLLM_ObjectPermissionTable
|
|
|
|
guardrail_error = HTTPException(status_code=400, detail={"error": "Content blocked"})
|
|
|
|
async def fake_contexts(user_api_key_auth):
|
|
return [user_api_key_auth]
|
|
|
|
async def fake_add_litellm_data_to_request(**kwargs):
|
|
return kwargs.get("data", {})
|
|
|
|
async def blocking_pre_call_hook(user_api_key_dict, data, call_type, skip_guardrails=False):
|
|
raise guardrail_error
|
|
|
|
failure_logging = AsyncMock()
|
|
monkeypatch.setattr(rest_endpoints, "build_effective_auth_contexts", fake_contexts, raising=False)
|
|
monkeypatch.setattr(
|
|
proxy_server, "add_litellm_data_to_request", fake_add_litellm_data_to_request, raising=False
|
|
)
|
|
monkeypatch.setattr(proxy_server, "proxy_config", {}, raising=False)
|
|
monkeypatch.setattr(proxy_server, "general_settings", {}, raising=False)
|
|
monkeypatch.setattr(proxy_server.proxy_logging_obj, "pre_call_hook", blocking_pre_call_hook)
|
|
monkeypatch.setattr(rest_endpoints, "fire_mcp_tool_call_failure_logging", failure_logging, raising=False)
|
|
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="hashed-key",
|
|
request_route="/mcp-rest/tools/call",
|
|
object_permission=LiteLLM_ObjectPermissionTable(
|
|
object_permission_id="search-scope",
|
|
mcp_tool_search_enabled=True,
|
|
),
|
|
)
|
|
request = _build_request(
|
|
path="/mcp-rest/tools/call",
|
|
method="POST",
|
|
json_body={"name": "mcp_tool_call", "arguments": {"tool_name": "x", "arguments": {"q": "confidential"}}},
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await rest_endpoints.call_tool_rest_api(request, user_api_key_dict=user_api_key_dict)
|
|
|
|
assert exc_info.value is guardrail_error
|
|
failure_logging.assert_awaited_once()
|
|
logging_obj, exception, _start_time, user_api_key_auth, request_data = failure_logging.await_args.args
|
|
assert exception is guardrail_error
|
|
assert user_api_key_auth is user_api_key_dict
|
|
assert logging_obj is request_data.get("litellm_logging_obj")
|
|
assert logging_obj is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("virtual", [False, True])
|
|
@pytest.mark.parametrize("selected", [False, True])
|
|
@pytest.mark.parametrize("action", ["block", "modify"])
|
|
async def test_request_selected_tool_specific_guardrail_applies_to_virtual_execution(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
virtual: bool,
|
|
selected: bool,
|
|
action: str,
|
|
) -> None:
|
|
import litellm
|
|
from litellm.caching.caching import DualCache
|
|
from litellm.proxy import proxy_server
|
|
from litellm.proxy._types import LiteLLM_ObjectPermissionTable
|
|
from litellm.proxy.guardrails.guardrail_hooks.custom_code.custom_code_guardrail import CustomCodeGuardrail
|
|
from litellm.proxy.mcp_server import mcp_server_manager, server, tool_registry
|
|
from litellm.proxy.utils import ProxyLogging
|
|
|
|
guardrail: Final = CustomCodeGuardrail(
|
|
guardrail_name="block-resolved-tool",
|
|
event_hook="pre_mcp_call",
|
|
default_on=False,
|
|
custom_code="def apply_guardrail(inputs, request_data, input_type):\n"
|
|
' function = inputs.get("tools", [{}])[0].get("function", {})\n'
|
|
' if function.get("name") == "execute" and "confidential" in inputs.get("texts", []):\n'
|
|
' texts = [text.replace("confidential", "redacted") for text in inputs.get("texts", [])]\n'
|
|
f' return {{"action": "{action}", "reason": "resolved tool blocked", "texts": texts}}\n'
|
|
" return allow()\n",
|
|
)
|
|
manager: Final = mcp_server_manager.MCPServerManager()
|
|
managed_server: Final = MCPServer(
|
|
server_id="observer",
|
|
name="observer",
|
|
server_name="observer",
|
|
transport="http",
|
|
url="https://observer.example/mcp",
|
|
spec_path="observer.json",
|
|
auth_type="none",
|
|
)
|
|
manager.registry = {"observer": managed_server}
|
|
manager.tool_name_to_mcp_server_name_mapping = {"observer-execute": "observer"}
|
|
upstream: Final = AsyncMock(return_value={"executed": True})
|
|
registry: Final = tool_registry.MCPToolRegistry()
|
|
registry.register_tool("observer-execute", "Execute", {"type": "object"}, upstream)
|
|
|
|
async def passthrough_request_data(data: dict[str, object], **kwargs: object) -> dict[str, object]:
|
|
return data
|
|
|
|
monkeypatch.setattr(litellm, "callbacks", [guardrail])
|
|
monkeypatch.setattr(tool_registry, "global_mcp_tool_registry", registry)
|
|
monkeypatch.setattr(mcp_server_manager, "global_mcp_server_manager", manager)
|
|
monkeypatch.setattr(mcp_operations, "global_mcp_tool_registry", registry)
|
|
monkeypatch.setattr(mcp_operations, "global_mcp_server_manager", manager)
|
|
monkeypatch.setattr(rest_endpoints, "global_mcp_server_manager", manager)
|
|
monkeypatch.setattr(mcp_operations, "_get_allowed_mcp_servers", AsyncMock(return_value=[managed_server]))
|
|
monkeypatch.setattr(proxy_server, "proxy_logging_obj", ProxyLogging(user_api_key_cache=DualCache()))
|
|
monkeypatch.setattr(proxy_server, "add_litellm_data_to_request", passthrough_request_data)
|
|
monkeypatch.setattr(proxy_server, "proxy_config", {})
|
|
monkeypatch.setattr(proxy_server, "general_settings", {})
|
|
caller: Final = UserAPIKeyAuth(
|
|
api_key="hashed-key",
|
|
request_route="/mcp-rest/tools/call",
|
|
object_permission=LiteLLM_ObjectPermissionTable(
|
|
object_permission_id="virtual-test",
|
|
mcp_servers=["observer"],
|
|
mcp_tool_search_enabled=True,
|
|
),
|
|
)
|
|
request: Final = _build_request(
|
|
path="/mcp-rest/tools/call",
|
|
method="POST",
|
|
json_body={
|
|
"name": "mcp_tool_call" if virtual else "observer-execute",
|
|
"server_id": "observer",
|
|
"arguments": {"tool_name": "observer-execute", "arguments": {"q": "confidential"}}
|
|
if virtual
|
|
else {"q": "confidential"},
|
|
"guardrails": ["block-resolved-tool"] if selected else [],
|
|
},
|
|
)
|
|
if selected and action == "block":
|
|
with pytest.raises(HTTPException) as error:
|
|
await rest_endpoints.call_tool_rest_api(request, user_api_key_dict=caller)
|
|
assert error.value.status_code == 400
|
|
assert error.value.detail["message"] == "resolved tool blocked"
|
|
upstream.assert_not_awaited()
|
|
else:
|
|
result: Final = await rest_endpoints.call_tool_rest_api(request, user_api_key_dict=caller)
|
|
assert result.is_error is False
|
|
upstream.assert_awaited_once()
|
|
assert upstream.await_args.kwargs == {"q": "redacted" if selected else "confidential"}
|
|
|
|
|
|
class TestGetToolsForSingleServer:
|
|
"""Test _get_tools_for_single_server with object_permission filtering"""
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
async def test_filters_tools_by_object_permission_mcp_tool_permissions(self, monkeypatch):
|
|
"""Test that tools are filtered by user_api_key_auth.object_permission.mcp_tool_permissions"""
|
|
from litellm.proxy._types import LiteLLM_ObjectPermissionTable
|
|
from litellm.proxy.mcp_server.server import MCPServer
|
|
from litellm.types.mcp import MCPTransport
|
|
|
|
mock_tools = [
|
|
Tool(name="tool1", description="First tool", inputSchema={}),
|
|
Tool(name="tool2", description="Second tool", inputSchema={}),
|
|
Tool(name="tool3", description="Third tool", inputSchema={}),
|
|
]
|
|
|
|
# Mock _get_tools_from_server to return all tools
|
|
async def fake_get_tools_from_server(**kwargs):
|
|
return mock_tools
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_tools_from_server",
|
|
fake_get_tools_from_server,
|
|
raising=False,
|
|
)
|
|
|
|
# Create server
|
|
server = MCPServer(
|
|
server_id="test-server-id",
|
|
name="test-server",
|
|
transport=MCPTransport.sse,
|
|
allowed_tools=None, # No server-level filtering
|
|
)
|
|
|
|
# Create UserAPIKeyAuth with object_permission
|
|
object_permission = LiteLLM_ObjectPermissionTable(
|
|
object_permission_id="test-permission-id",
|
|
mcp_tool_permissions={"test-server-id": ["tool1", "tool3"]},
|
|
)
|
|
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="test-key",
|
|
object_permission=object_permission,
|
|
)
|
|
|
|
# Call the function
|
|
result = await rest_endpoints._get_tools_for_single_server(
|
|
server=server,
|
|
server_auth_header=None,
|
|
user_api_key_auth=user_api_key_dict,
|
|
)
|
|
|
|
# Verify only allowed tools are returned
|
|
assert len(result) == 2
|
|
tool_names = [tool.name for tool in result]
|
|
assert "tool1" in tool_names
|
|
assert "tool3" in tool_names
|
|
assert "tool2" not in tool_names
|
|
|
|
async def test_no_filtering_when_object_permission_is_none(self, monkeypatch):
|
|
"""Test that all tools are returned when object_permission is None"""
|
|
from litellm.proxy.mcp_server.server import MCPServer
|
|
from litellm.types.mcp import MCPTransport
|
|
|
|
mock_tools = [
|
|
Tool(name="tool1", description="First tool", inputSchema={}),
|
|
Tool(name="tool2", description="Second tool", inputSchema={}),
|
|
]
|
|
|
|
async def fake_get_tools_from_server(**kwargs):
|
|
return mock_tools
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_tools_from_server",
|
|
fake_get_tools_from_server,
|
|
raising=False,
|
|
)
|
|
|
|
server = MCPServer(
|
|
server_id="test-server-id",
|
|
name="test-server",
|
|
transport=MCPTransport.sse,
|
|
allowed_tools=None,
|
|
)
|
|
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="test-key",
|
|
object_permission=None,
|
|
)
|
|
|
|
result = await rest_endpoints._get_tools_for_single_server(
|
|
server=server,
|
|
server_auth_header=None,
|
|
user_api_key_auth=user_api_key_dict,
|
|
)
|
|
|
|
# All tools should be returned
|
|
assert len(result) == 2
|
|
|
|
async def test_no_filtering_when_mcp_tool_permissions_is_none(self, monkeypatch):
|
|
"""Test that all tools are returned when mcp_tool_permissions is None"""
|
|
from litellm.proxy._types import LiteLLM_ObjectPermissionTable
|
|
from litellm.proxy.mcp_server.server import MCPServer
|
|
from litellm.types.mcp import MCPTransport
|
|
|
|
mock_tools = [
|
|
Tool(name="tool1", description="First tool", inputSchema={}),
|
|
Tool(name="tool2", description="Second tool", inputSchema={}),
|
|
]
|
|
|
|
async def fake_get_tools_from_server(**kwargs):
|
|
return mock_tools
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_tools_from_server",
|
|
fake_get_tools_from_server,
|
|
raising=False,
|
|
)
|
|
|
|
server = MCPServer(
|
|
server_id="test-server-id",
|
|
name="test-server",
|
|
transport=MCPTransport.sse,
|
|
allowed_tools=None,
|
|
)
|
|
|
|
object_permission = LiteLLM_ObjectPermissionTable(
|
|
object_permission_id="test-permission-id",
|
|
mcp_tool_permissions=None, # No tool permissions set
|
|
)
|
|
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="test-key",
|
|
object_permission=object_permission,
|
|
)
|
|
|
|
result = await rest_endpoints._get_tools_for_single_server(
|
|
server=server,
|
|
server_auth_header=None,
|
|
user_api_key_auth=user_api_key_dict,
|
|
)
|
|
|
|
# All tools should be returned
|
|
assert len(result) == 2
|
|
|
|
async def test_no_filtering_when_server_not_in_mcp_tool_permissions(self, monkeypatch):
|
|
"""Test that all tools are returned when server is not in mcp_tool_permissions"""
|
|
from litellm.proxy._types import LiteLLM_ObjectPermissionTable
|
|
from litellm.proxy.mcp_server.server import MCPServer
|
|
from litellm.types.mcp import MCPTransport
|
|
|
|
mock_tools = [
|
|
Tool(name="tool1", description="First tool", inputSchema={}),
|
|
Tool(name="tool2", description="Second tool", inputSchema={}),
|
|
]
|
|
|
|
async def fake_get_tools_from_server(**kwargs):
|
|
return mock_tools
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_tools_from_server",
|
|
fake_get_tools_from_server,
|
|
raising=False,
|
|
)
|
|
|
|
server = MCPServer(
|
|
server_id="test-server-id",
|
|
name="test-server",
|
|
transport=MCPTransport.sse,
|
|
allowed_tools=None,
|
|
)
|
|
|
|
object_permission = LiteLLM_ObjectPermissionTable(
|
|
object_permission_id="test-permission-id",
|
|
mcp_tool_permissions={"other-server-id": ["tool1"]}, # Different server
|
|
)
|
|
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="test-key",
|
|
object_permission=object_permission,
|
|
)
|
|
|
|
result = await rest_endpoints._get_tools_for_single_server(
|
|
server=server,
|
|
server_auth_header=None,
|
|
user_api_key_auth=user_api_key_dict,
|
|
)
|
|
|
|
# All tools should be returned since server is not in permissions
|
|
assert len(result) == 2
|
|
|
|
async def test_combines_server_allowed_tools_and_object_permission_filters(self, monkeypatch):
|
|
"""Test that both server.allowed_tools and object_permission.mcp_tool_permissions filters are applied"""
|
|
from litellm.proxy._types import LiteLLM_ObjectPermissionTable
|
|
from litellm.proxy.mcp_server.server import MCPServer
|
|
from litellm.types.mcp import MCPTransport
|
|
|
|
mock_tools = [
|
|
Tool(name="tool1", description="First tool", inputSchema={}),
|
|
Tool(name="tool2", description="Second tool", inputSchema={}),
|
|
Tool(name="tool3", description="Third tool", inputSchema={}),
|
|
Tool(name="tool4", description="Fourth tool", inputSchema={}),
|
|
]
|
|
|
|
async def fake_get_tools_from_server(**kwargs):
|
|
return mock_tools
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_tools_from_server",
|
|
fake_get_tools_from_server,
|
|
raising=False,
|
|
)
|
|
|
|
# Server allows tool1, tool2, tool3
|
|
server = MCPServer(
|
|
server_id="test-server-id",
|
|
name="test-server",
|
|
transport=MCPTransport.sse,
|
|
allowed_tools=["tool1", "tool2", "tool3"],
|
|
)
|
|
|
|
# Object permission allows tool2, tool3, tool4
|
|
object_permission = LiteLLM_ObjectPermissionTable(
|
|
object_permission_id="test-permission-id",
|
|
mcp_tool_permissions={"test-server-id": ["tool2", "tool3", "tool4"]},
|
|
)
|
|
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="test-key",
|
|
object_permission=object_permission,
|
|
)
|
|
|
|
result = await rest_endpoints._get_tools_for_single_server(
|
|
server=server,
|
|
server_auth_header=None,
|
|
user_api_key_auth=user_api_key_dict,
|
|
)
|
|
|
|
# Only tools in both lists should be returned (intersection): tool2, tool3
|
|
assert len(result) == 2
|
|
tool_names = [tool.name for tool in result]
|
|
assert "tool2" in tool_names
|
|
assert "tool3" in tool_names
|
|
assert "tool1" not in tool_names
|
|
assert "tool4" not in tool_names
|
|
|
|
async def test_apply_tool_filters_false_returns_full_catalog(self, monkeypatch):
|
|
"""apply_tool_filters=False returns the raw catalog without the server
|
|
allowed_tools gate, so the config UI can render disabled tools as off."""
|
|
from litellm.proxy.mcp_server.server import MCPServer
|
|
from litellm.types.mcp import MCPTransport
|
|
|
|
mock_tools = [
|
|
Tool(name="tool1", description="tool1", inputSchema={}),
|
|
Tool(name="tool2", description="tool2", inputSchema={}),
|
|
Tool(name="tool3", description="tool3", inputSchema={}),
|
|
]
|
|
|
|
async def fake_get_tools_from_server(**kwargs):
|
|
return mock_tools
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_tools_from_server",
|
|
fake_get_tools_from_server,
|
|
raising=False,
|
|
)
|
|
|
|
# Server enforces an allowlist of just tool1.
|
|
server = MCPServer(
|
|
server_id="test-server-id",
|
|
name="test-server",
|
|
transport=MCPTransport.sse,
|
|
allowed_tools=["tool1"],
|
|
)
|
|
user_api_key_dict = UserAPIKeyAuth(api_key="test-key", object_permission=None)
|
|
|
|
# Runtime default: only the allowed tool comes back.
|
|
filtered = await rest_endpoints._get_tools_for_single_server(
|
|
server=server,
|
|
server_auth_header=None,
|
|
user_api_key_auth=user_api_key_dict,
|
|
)
|
|
assert [t.name for t in filtered] == ["tool1"]
|
|
|
|
# Config view: full catalog, including the disabled tools.
|
|
full = await rest_endpoints._get_tools_for_single_server(
|
|
server=server,
|
|
server_auth_header=None,
|
|
user_api_key_auth=user_api_key_dict,
|
|
apply_tool_filters=False,
|
|
)
|
|
assert {t.name for t in full} == {"tool1", "tool2", "tool3"}
|
|
|
|
|
|
class TestStdioCommandAllowlist:
|
|
"""Tests for MCP stdio command allowlist validation."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _stdio_enabled(self, monkeypatch):
|
|
monkeypatch.setenv("LITELLM_ENABLE_MCP_STDIO", "true")
|
|
|
|
def test_allowed_command_passes_validation(self):
|
|
"""npx, uvx, python, etc. should be accepted."""
|
|
req = NewMCPServerRequest(
|
|
server_name="test",
|
|
transport="stdio",
|
|
command="npx",
|
|
args=["-y", "@modelcontextprotocol/server-filesystem"],
|
|
)
|
|
assert req.command == "npx"
|
|
|
|
def test_disallowed_command_raises(self):
|
|
"""Arbitrary commands like bash should be rejected."""
|
|
with pytest.raises(ValueError, match="not in the allowed commands list"):
|
|
NewMCPServerRequest(
|
|
server_name="test",
|
|
transport="stdio",
|
|
command="bash",
|
|
args=["-c", "echo pwned"],
|
|
)
|
|
|
|
def test_sh_command_raises(self):
|
|
"""sh should be rejected."""
|
|
with pytest.raises(ValueError, match="not in the allowed commands list"):
|
|
NewMCPServerRequest(
|
|
server_name="test",
|
|
transport="stdio",
|
|
command="sh",
|
|
args=["-c", "id > /tmp/output.txt"],
|
|
)
|
|
|
|
def test_absolute_path_bypass_blocked(self):
|
|
"""/bin/bash should be blocked (basename is 'bash')."""
|
|
with pytest.raises(ValueError, match="not in the allowed commands list"):
|
|
NewMCPServerRequest(
|
|
server_name="test",
|
|
transport="stdio",
|
|
command="/bin/bash",
|
|
args=["-c", "echo pwned"],
|
|
)
|
|
|
|
def test_absolute_path_to_allowed_command_works(self):
|
|
"""/usr/bin/python3 should pass (basename is 'python3')."""
|
|
req = NewMCPServerRequest(
|
|
server_name="test",
|
|
transport="stdio",
|
|
command="/usr/bin/python3",
|
|
args=["-m", "some_module"],
|
|
)
|
|
assert req.command == "/usr/bin/python3"
|
|
|
|
def test_http_transport_ignores_allowlist(self):
|
|
"""HTTP/SSE transport should not trigger command validation."""
|
|
req = NewMCPServerRequest(
|
|
server_name="test",
|
|
transport="sse",
|
|
url="https://example.com/mcp",
|
|
)
|
|
assert req.transport == "sse"
|
|
|
|
def test_uvx_command_passes(self):
|
|
req = NewMCPServerRequest(
|
|
server_name="test",
|
|
transport="stdio",
|
|
command="uvx",
|
|
args=["mcp-server-sqlite"],
|
|
)
|
|
assert req.command == "uvx"
|
|
|
|
def test_node_command_passes(self):
|
|
req = NewMCPServerRequest(
|
|
server_name="test",
|
|
transport="stdio",
|
|
command="node",
|
|
args=["server.js"],
|
|
)
|
|
assert req.command == "node"
|
|
|
|
def test_update_request_disallowed_command_raises(self):
|
|
"""UpdateMCPServerRequest should also block non-allowlisted commands."""
|
|
with pytest.raises(ValueError, match="not in the allowed commands list"):
|
|
UpdateMCPServerRequest(
|
|
server_id="some-id",
|
|
transport="stdio",
|
|
command="bash",
|
|
args=["-c", "echo pwned"],
|
|
)
|
|
|
|
|
|
class TestEndpointRoleChecks:
|
|
"""Tests for PROXY_ADMIN role checks on MCP test endpoints."""
|
|
|
|
def test_test_connection_has_auth_dependency(self):
|
|
route = _get_route("/mcp-rest/test/connection", "POST")
|
|
assert _route_has_dependency(route, user_api_key_auth)
|
|
|
|
def test_test_tools_list_has_auth_dependency(self):
|
|
route = _get_route("/mcp-rest/test/tools/list", "POST")
|
|
assert _route_has_dependency(route, user_api_key_auth)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_test_connection_rejects_non_admin(self):
|
|
"""Non-admin users should get 403 from test_connection."""
|
|
from litellm.proxy._types import LitellmUserRoles
|
|
|
|
payload = NewMCPServerRequest(
|
|
server_name="test",
|
|
url="https://example.com/mcp",
|
|
auth_type=MCPAuth.none,
|
|
)
|
|
user_key = UserAPIKeyAuth(
|
|
user_role=LitellmUserRoles.INTERNAL_USER,
|
|
user_id="non_admin",
|
|
api_key="sk-test",
|
|
)
|
|
request = _build_request()
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await rest_endpoints.test_connection(
|
|
request=request,
|
|
new_mcp_server_request=payload,
|
|
user_api_key_dict=user_key,
|
|
)
|
|
assert exc_info.value.status_code == 403
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_test_tools_list_rejects_non_admin(self):
|
|
"""Non-admin users should get 403 from test_tools_list."""
|
|
from litellm.proxy._types import LitellmUserRoles
|
|
|
|
payload = NewMCPServerRequest(
|
|
server_name="test",
|
|
url="https://example.com/mcp",
|
|
auth_type=MCPAuth.none,
|
|
)
|
|
user_key = UserAPIKeyAuth(
|
|
user_role=LitellmUserRoles.INTERNAL_USER,
|
|
user_id="non_admin",
|
|
api_key="sk-test",
|
|
)
|
|
request = _build_request()
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await rest_endpoints.test_tools_list(
|
|
request=request,
|
|
new_mcp_server_request=payload,
|
|
user_api_key_dict=user_key,
|
|
)
|
|
assert exc_info.value.status_code == 403
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_test_connection_allows_admin(self, monkeypatch):
|
|
"""PROXY_ADMIN should pass the role check."""
|
|
from litellm.proxy._types import LitellmUserRoles
|
|
|
|
async def fake_execute(*args, **kwargs):
|
|
return {"status": "ok"}
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints,
|
|
"_execute_with_mcp_client",
|
|
fake_execute,
|
|
)
|
|
|
|
payload = NewMCPServerRequest(
|
|
server_name="test",
|
|
url="https://example.com/mcp",
|
|
auth_type=MCPAuth.none,
|
|
)
|
|
user_key = UserAPIKeyAuth(
|
|
user_role=LitellmUserRoles.PROXY_ADMIN,
|
|
user_id="admin",
|
|
api_key="sk-admin",
|
|
)
|
|
request = _build_request()
|
|
|
|
result = await rest_endpoints.test_connection(
|
|
request=request,
|
|
new_mcp_server_request=payload,
|
|
user_api_key_dict=user_key,
|
|
)
|
|
assert result["status"] == "ok"
|
|
|
|
|
|
class TestPreviewOpenAPITools:
|
|
"""Verify the OpenAPI preview endpoint emits provider-safe tool names.
|
|
|
|
Regression: GitHub's OpenAPI spec uses tag-namespaced operationIds like
|
|
`actions/download-job-logs-for-workflow-run` which contain '/'. The
|
|
preview must sanitize so what the dashboard shows matches what gets
|
|
registered (and what makes it past LLM provider tool-name validation).
|
|
"""
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
async def test_preview_sanitizes_slash_in_operation_id(self, monkeypatch):
|
|
import re
|
|
|
|
async def fake_load_spec(spec_path): # noqa: ANN001
|
|
return {
|
|
"paths": {
|
|
"/repos/{owner}/{repo}/actions/jobs/{job_id}/logs": {
|
|
"get": {
|
|
"operationId": ("actions/download-job-logs-for-workflow-run"),
|
|
"summary": "Download job logs",
|
|
}
|
|
},
|
|
"/repos/{owner}/{repo}/pulls/{pull_number}/files": {
|
|
"get": {
|
|
"operationId": "pulls/list-files",
|
|
"summary": "List files",
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
from litellm.proxy.mcp_server import (
|
|
openapi_to_mcp_generator,
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
openapi_to_mcp_generator,
|
|
"load_openapi_spec_async",
|
|
fake_load_spec,
|
|
raising=False,
|
|
)
|
|
|
|
payload = NewMCPServerRequest(
|
|
server_name="github_openapi_mcp",
|
|
spec_path="https://example.invalid/openapi.json",
|
|
transport="http",
|
|
)
|
|
request = _build_request()
|
|
|
|
from litellm.proxy._types import LitellmUserRoles
|
|
|
|
result = await rest_endpoints.test_tools_list(
|
|
request,
|
|
payload,
|
|
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
|
|
)
|
|
|
|
assert result.get("error") is None, result
|
|
names = [t["name"] for t in result["tools"]]
|
|
anthropic_re = re.compile(r"^[a-zA-Z0-9_-]{1,128}$")
|
|
for name in names:
|
|
assert anthropic_re.match(name), f"preview tool name {name!r} violates ^[a-zA-Z0-9_-]+$"
|
|
assert "actions_download-job-logs-for-workflow-run" in names
|
|
assert "pulls_list-files" in names
|
|
|
|
async def test_preview_method_order_matches_registration(self, monkeypatch):
|
|
"""Preview must iterate HTTP methods in the same order as
|
|
register_tools_from_openapi, otherwise collision-disambiguation
|
|
suffixes (_2, _3, ...) get assigned to different operations and the
|
|
dashboard shows names that differ from what's actually registered.
|
|
"""
|
|
from litellm.proxy.mcp_server import (
|
|
openapi_to_mcp_generator,
|
|
)
|
|
|
|
spec = {
|
|
"paths": {
|
|
"/items/{id}": {
|
|
"delete": {
|
|
"operationId": "items/delete",
|
|
"summary": "Delete item",
|
|
},
|
|
"patch": {
|
|
"operationId": "items.delete",
|
|
"summary": "Soft-delete item",
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
async def fake_load_spec(spec_path): # noqa: ANN001
|
|
return spec
|
|
|
|
monkeypatch.setattr(
|
|
openapi_to_mcp_generator,
|
|
"load_openapi_spec_async",
|
|
fake_load_spec,
|
|
raising=False,
|
|
)
|
|
|
|
payload = NewMCPServerRequest(
|
|
server_name="collision_openapi_mcp",
|
|
spec_path="https://example.invalid/openapi.json",
|
|
transport="http",
|
|
)
|
|
request = _build_request()
|
|
from litellm.proxy._types import LitellmUserRoles
|
|
|
|
result = await rest_endpoints.test_tools_list(
|
|
request,
|
|
payload,
|
|
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
|
|
)
|
|
assert result.get("error") is None, result
|
|
preview_summary_to_name = {t["description"]: t["name"] for t in result["tools"]}
|
|
|
|
registered_summary_to_name: dict = {}
|
|
|
|
def fake_create_tool_function(path, method, operation, base_url): # noqa: ANN001
|
|
def _f():
|
|
return None
|
|
|
|
return _f
|
|
|
|
monkeypatch.setattr(
|
|
openapi_to_mcp_generator,
|
|
"create_tool_function",
|
|
fake_create_tool_function,
|
|
)
|
|
|
|
class _StubRegistry:
|
|
def register_tool(self, name, description, input_schema, handler): # noqa: ANN001
|
|
registered_summary_to_name[description] = name
|
|
|
|
monkeypatch.setattr(
|
|
openapi_to_mcp_generator,
|
|
"global_mcp_tool_registry",
|
|
_StubRegistry(),
|
|
)
|
|
|
|
openapi_to_mcp_generator.register_tools_from_openapi(spec, base_url="https://example.invalid")
|
|
|
|
assert preview_summary_to_name == registered_summary_to_name, (
|
|
f"preview {preview_summary_to_name} != "
|
|
f"registered {registered_summary_to_name} — method iteration "
|
|
"order is out of sync, so collision suffixes (_2, _3, ...) "
|
|
"land on different operations"
|
|
)
|
|
|
|
|
|
class TestConnectionErrorMessage:
|
|
"""The test-connection endpoints turn raw transport errors into messages.
|
|
|
|
The message is returned to an admin in an API response, so it must explain
|
|
the failure without echoing the raw header value, which can carry a secret
|
|
(e.g. ``Authorization: Bearer <token>``).
|
|
"""
|
|
|
|
def test_local_protocol_error_is_actionable_and_redacted(self):
|
|
secret = "Bearer sk-super-secret-token"
|
|
exc = httpx.LocalProtocolError(f"Illegal header value b' {secret}'")
|
|
|
|
message = rest_endpoints._connection_error_message(exc, "https://example.com", 30.0)
|
|
|
|
assert "header" in message.lower()
|
|
assert secret not in message
|
|
|
|
def test_connect_error_points_at_reachability(self):
|
|
message = rest_endpoints._connection_error_message(
|
|
httpx.ConnectError("All connection attempts failed"), "https://example.com", 30.0
|
|
)
|
|
assert "unreachable" in message.lower()
|
|
|
|
def test_timeout_error_message(self):
|
|
message = rest_endpoints._connection_error_message(
|
|
httpx.ConnectTimeout("timed out"), "https://example.com", 30.0
|
|
)
|
|
assert "unreachable" in message.lower()
|
|
|
|
def test_http_status_error_includes_status_code(self):
|
|
response = httpx.Response(status_code=503)
|
|
exc = httpx.HTTPStatusError(
|
|
"server error",
|
|
request=httpx.Request("POST", "http://x/"),
|
|
response=response,
|
|
)
|
|
message = rest_endpoints._connection_error_message(exc, "https://example.com", 30.0)
|
|
assert "503" in message
|
|
|
|
@pytest.mark.parametrize(
|
|
"error_type", [httpx.ReadError, httpx.WriteError, httpx.RemoteProtocolError, ConnectionResetError]
|
|
)
|
|
def test_interrupted_connection_message_is_safe(self, error_type: type[Exception]) -> None:
|
|
message: Final = rest_endpoints._connection_error_message(
|
|
error_type("secret-transport-detail"), "https://example.com/?token=secret-query", 30
|
|
)
|
|
assert "connection was interrupted" in message
|
|
assert "secret" not in message
|
|
|
|
def test_closed_connection_explains_incomplete_request(self) -> None:
|
|
from mcp import MCPError
|
|
from mcp.types import ErrorData
|
|
|
|
message: Final = rest_endpoints._connection_error_message(
|
|
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
|
|
|
|
def test_timeout_does_not_claim_the_server_sent_nothing(self) -> None:
|
|
message: Final = rest_endpoints._connection_error_message(TimeoutError(), None, 30)
|
|
assert "no valid MCP response received" in message
|
|
|
|
@pytest.mark.asyncio
|
|
@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.types import REQUEST_TIMEOUT, ErrorData
|
|
|
|
async def operation(client: rest_endpoints.MCPClient) -> dict[str, object]:
|
|
try:
|
|
raise TimeoutError("secret-timeout")
|
|
except TimeoutError as elapsed:
|
|
if not sdk_timeout:
|
|
raise
|
|
try:
|
|
raise MCPError(code=REQUEST_TIMEOUT, message="secret-sdk-timeout") from elapsed
|
|
except MCPError as sdk_error:
|
|
raise TimeoutError() from sdk_error
|
|
|
|
payload: Final = NewMCPServerRequest(
|
|
server_name="timeout", url="https://example.com", auth_type=MCPAuth.none, timeout=read_timeout
|
|
)
|
|
result: Final = await rest_endpoints._execute_with_mcp_client(payload, operation, timeout_seconds=30)
|
|
assert (f"within {read_timeout}s" if sdk_timeout else "within 30s") in result["message"]
|
|
assert "secret" not in result["message"]
|
|
|
|
def test_unknown_error_falls_back_to_generic(self):
|
|
message = rest_endpoints._connection_error_message(RuntimeError("weird"), "https://example.com", 30.0)
|
|
assert "weird" not in message
|
|
assert "reference" in message.lower()
|
|
|
|
def test_sdk_session_terminated_explains_endpoint_and_retry(self) -> None:
|
|
from mcp.shared.exceptions import MCPError
|
|
from mcp.types import ErrorData
|
|
|
|
message: Final = rest_endpoints._connection_error_message(
|
|
MCPError(code=32600, message="Session terminated"), "https://example.com/mcp", 30.0
|
|
)
|
|
|
|
assert "session was terminated" in message
|
|
assert "MCP endpoint" in message
|
|
assert "transport" in message
|
|
assert "retry" in message
|
|
assert "404" not in message
|
|
|
|
@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.types import ErrorData
|
|
|
|
message: Final = rest_endpoints._connection_error_message(
|
|
MCPError(code=code, message="secret-message", data={"token": "secret-data"}),
|
|
"https://example.com/secret-path?token=secret-query",
|
|
30.0,
|
|
)
|
|
|
|
assert f"JSON-RPC code {code}" in message
|
|
assert "secret" not in message
|
|
assert "timed out" not in message
|
|
assert "session was terminated" not in message
|
|
|
|
@pytest.mark.parametrize("status_code", [401, 403, 404, 405, 429, 503])
|
|
def test_wrapped_http_failures_preserve_status(self, status_code: int) -> None:
|
|
response: Final = httpx.Response(status_code, text="secret-body")
|
|
upstream: Final = httpx.HTTPStatusError(
|
|
"secret-exception",
|
|
request=httpx.Request("POST", "https://example.com/?token=secret-query"),
|
|
response=response,
|
|
)
|
|
wrapped: Final = BaseExceptionGroup(
|
|
"secret-group", [asyncio.CancelledError(), BaseExceptionGroup("nested", [upstream])]
|
|
)
|
|
|
|
message: Final = rest_endpoints._connection_error_message(wrapped, "https://example.com", 30.0)
|
|
|
|
assert f"HTTP {status_code}" in message
|
|
assert "secret" not in message
|
|
|
|
def test_explicit_cause_is_classified_before_incidental_context(self) -> None:
|
|
wrapped: Final = RuntimeError("secret-wrapper")
|
|
wrapped.__cause__ = httpx.ConnectError("secret-cause")
|
|
wrapped.__context__ = TimeoutError("secret-context")
|
|
|
|
message: Final = rest_endpoints._connection_error_message(wrapped, "https://example.com", 30.0)
|
|
|
|
assert "unreachable" in message
|
|
assert "secret" not in message
|
|
|
|
def test_timeout_url_redacts_credentials_path_query_and_fragment(self) -> None:
|
|
message: Final = rest_endpoints._connection_error_message(
|
|
TimeoutError("secret-error"),
|
|
"https://secret-user:secret-pass@example.com:8443/secret-path?token=secret-query#secret-fragment",
|
|
30.0,
|
|
)
|
|
|
|
assert "https://example.com:8443" in message
|
|
assert "30s" in message
|
|
assert "secret" not in message
|
|
|
|
def test_unknown_failure_reference_matches_safe_diagnostics(self, caplog: pytest.LogCaptureFixture) -> None:
|
|
import re
|
|
|
|
try:
|
|
raise RuntimeError("secret-exception-body")
|
|
except RuntimeError as exc:
|
|
message: Final = rest_endpoints._connection_error_message(
|
|
exc, "https://secret-user:secret-password@example.com/secret-path?token=secret-query", 30.0
|
|
)
|
|
|
|
reference: Final = re.search(r"reference ([a-f0-9]{32})", message)
|
|
assert reference is not None
|
|
diagnostics: Final = tuple(
|
|
record for record in caplog.records if "MCP connection test failed" in record.message
|
|
)
|
|
assert len(diagnostics) == 1
|
|
assert reference.group(1) in diagnostics[0].message
|
|
assert "RuntimeError" in diagnostics[0].message
|
|
assert "test_unknown_failure_reference_matches_safe_diagnostics" in diagnostics[0].message
|
|
assert diagnostics[0].exc_info is None
|
|
assert "secret" not in message + diagnostics[0].message
|
|
|
|
@pytest.mark.parametrize("exc", [ValueError("secret-config"), HTTPException(500, "secret-detail")])
|
|
def test_unrelated_errors_are_not_misreported_as_invalid_mcp(self, exc: Exception) -> None:
|
|
message: Final = rest_endpoints._connection_error_message(exc, "https://example.com", 30.0)
|
|
|
|
assert "reference" in message
|
|
assert "invalid MCP response" not in message
|
|
assert "secret" not in message
|
|
|
|
def test_configuration_validation_error_uses_unknown_fallback(self) -> None:
|
|
from pydantic import ValidationError
|
|
|
|
with pytest.raises(ValidationError) as caught:
|
|
NewMCPServerRequest.model_validate({"server_name": "example", "transport": "secret-invalid-transport"})
|
|
|
|
message: Final = rest_endpoints._connection_error_message(caught.value, "https://example.com", 30.0)
|
|
assert "reference" in message
|
|
assert "invalid MCP response" not in message
|
|
assert "secret" not in message
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connection_test_preserves_cancellation(self) -> None:
|
|
async def cancelled_operation(client: rest_endpoints.MCPClient) -> dict[str, object]:
|
|
raise asyncio.CancelledError
|
|
|
|
payload: Final = NewMCPServerRequest(server_name="cancelled", url="https://example.com", auth_type=MCPAuth.none)
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await rest_endpoints._execute_with_mcp_client(payload, cancelled_operation)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_failure_preserves_response_contract(self) -> None:
|
|
async def failing_operation(client: rest_endpoints.MCPClient) -> dict[str, object]:
|
|
raise RuntimeError("secret-operation")
|
|
|
|
payload: Final = NewMCPServerRequest(server_name="unknown", url="https://example.com", auth_type=MCPAuth.none)
|
|
result: Final = await rest_endpoints._execute_with_mcp_client(payload, failing_operation)
|
|
|
|
assert result["error"] is True
|
|
assert result["status"] == "error"
|
|
assert "reference" in result["message"]
|
|
assert "secret" not in result["message"]
|
|
assert "stack_trace" not in result
|
|
|
|
|
|
class TestGetServerAuthHeaderGroupDefault:
|
|
"""``x-mcp-<access_group>-authorization`` is the default for group members, the per-server
|
|
header still wins, and servers outside the group never see the group credential."""
|
|
|
|
@staticmethod
|
|
def _server(alias: str, group: str) -> MCPServer:
|
|
return MCPServer(
|
|
server_id=f"server-{alias}",
|
|
name=alias,
|
|
server_name=alias,
|
|
alias=alias,
|
|
url="https://example.com/mcp",
|
|
transport=MCPTransport.http,
|
|
access_groups=[group],
|
|
)
|
|
|
|
def test_group_header_applies_to_members_and_per_server_header_overrides(self):
|
|
headers = {
|
|
"shared": {"Authorization": "Bearer group-token"},
|
|
"beta": {"Authorization": "Bearer beta-token"},
|
|
}
|
|
assert rest_endpoints._get_server_auth_header(self._server("alpha", "shared"), headers, None) == {
|
|
"Authorization": "Bearer group-token"
|
|
}
|
|
assert rest_endpoints._get_server_auth_header(self._server("beta", "shared"), headers, None) == {
|
|
"Authorization": "Bearer beta-token"
|
|
}
|
|
|
|
def test_group_header_falls_back_to_legacy_header_outside_group(self):
|
|
headers = {"shared": {"Authorization": "Bearer group-token"}}
|
|
assert rest_endpoints._get_server_auth_header(self._server("gamma", "other"), headers, None) is None
|
|
assert rest_endpoints._get_server_auth_header(self._server("gamma", "other"), headers, "legacy") == "legacy"
|
|
|
|
|
|
class TestToolResponseMcpInfoEnrichment:
|
|
"""The REST tools/list response must expose the user-facing alias and the
|
|
server_id alongside the internal server_name so clients (agent builder UIs)
|
|
can map the internal config key to a friendly name without needing the
|
|
mcp_routes-gated server listing.
|
|
"""
|
|
|
|
def test_enriches_mcp_info_with_alias_and_server_id(self):
|
|
from mcp.types import Tool as MCPTool
|
|
|
|
from litellm.proxy.mcp_server.server import MCPServer
|
|
from litellm.types.mcp import MCPTransport
|
|
|
|
server = MCPServer(
|
|
server_id="a1b2c3d4",
|
|
name="mcpAtlassian",
|
|
alias="atlassian",
|
|
server_name="mcpAtlassian",
|
|
transport=MCPTransport.http,
|
|
mcp_info={"server_name": "mcpAtlassian"},
|
|
)
|
|
tools = [
|
|
MCPTool(
|
|
name="get_issue",
|
|
description="Fetch a Jira issue",
|
|
inputSchema={"type": "object"},
|
|
)
|
|
]
|
|
|
|
result = rest_endpoints._create_tool_response_objects(tools, server)
|
|
|
|
assert result[0].mcp_info == {
|
|
"server_name": "mcpAtlassian",
|
|
"server_id": "a1b2c3d4",
|
|
"alias": "atlassian",
|
|
}
|
|
|
|
from fastapi.encoders import jsonable_encoder
|
|
|
|
wire = jsonable_encoder(result[0])
|
|
assert wire["inputSchema"] == {"type": "object"}
|
|
assert wire["mcp_info"] == result[0].mcp_info
|
|
|
|
def test_alias_none_is_explicit_in_mcp_info(self):
|
|
from mcp.types import Tool as MCPTool
|
|
|
|
from litellm.proxy.mcp_server.server import MCPServer
|
|
from litellm.types.mcp import MCPTransport
|
|
|
|
server = MCPServer(
|
|
server_id="server-uuid",
|
|
name="no_alias_server",
|
|
alias=None,
|
|
server_name="no_alias_server",
|
|
transport=MCPTransport.http,
|
|
mcp_info={"server_name": "no_alias_server"},
|
|
)
|
|
tools = [
|
|
MCPTool(
|
|
name="ping",
|
|
description="Ping",
|
|
inputSchema={"type": "object"},
|
|
)
|
|
]
|
|
|
|
result = rest_endpoints._create_tool_response_objects(tools, server)
|
|
|
|
assert result[0].mcp_info == {
|
|
"server_name": "no_alias_server",
|
|
"server_id": "server-uuid",
|
|
"alias": None,
|
|
}
|
|
|
|
def test_preserves_complete_sdk_tool_definition(self) -> None:
|
|
from mcp.types import Tool
|
|
|
|
tool: Final = Tool.model_validate(
|
|
{
|
|
"name": "quote",
|
|
"title": "Quote",
|
|
"description": "Return a quote",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"$defs": {"amount": {"type": "number", "minimum": 0.25}},
|
|
"properties": {"amount": {"$ref": "#/$defs/amount"}},
|
|
"anyOf": [{"required": ["amount"]}, {"maxProperties": 0}],
|
|
},
|
|
"outputSchema": {"type": "object", "properties": {"price": {"type": "number", "multipleOf": 0.25}}},
|
|
"annotations": {"readOnlyHint": True},
|
|
"_meta": {"display": {"priority": 0.75}},
|
|
"icons": [{"src": "https://example.com/icon.png"}],
|
|
}
|
|
)
|
|
original: Final = tool.model_dump(by_alias=True)
|
|
server: Final = MCPServer(server_id="quotes", name="quotes", transport=MCPTransport.http)
|
|
response: Final = rest_endpoints._create_tool_response_objects([tool], server)[0]
|
|
assert response.model_dump(by_alias=True, exclude={"mcp_info"}) == original
|
|
assert tool.model_dump(by_alias=True) == original
|
|
|
|
|
|
class TestRestListToolsetFiltering:
|
|
@pytest.mark.asyncio
|
|
async def test_rest_list_filters_toolset_only_key_to_toolset_tools(self, monkeypatch):
|
|
"""A toolset-only key reaching a toolset server via REST list must see
|
|
only the toolset's tools; the raw catalog leaked every tool on the
|
|
server when the filter read object_permission directly instead of the
|
|
shared toolset-aware primitive"""
|
|
from unittest.mock import patch
|
|
|
|
from mcp.types import Tool as MCPTool
|
|
|
|
from litellm.proxy.mcp_server.auth.user_api_key_auth_mcp import (
|
|
MCPRequestHandler,
|
|
)
|
|
from litellm.proxy.mcp_server.server import MCPServer
|
|
from litellm.types.mcp import MCPTransport
|
|
|
|
stub_server = MCPServer(
|
|
server_id="server-a",
|
|
name="stubtools",
|
|
transport=MCPTransport.http,
|
|
)
|
|
stub_server.alias = "stubtools"
|
|
stub_server.server_name = "stubtools"
|
|
stub_server.allowed_tools = None
|
|
stub_server.disallowed_tools = None
|
|
stub_server.mcp_info = {"server_name": "stubtools"}
|
|
|
|
upstream_tools = [
|
|
MCPTool(name="lookup_status", inputSchema={"type": "object"}),
|
|
MCPTool(name="delete_everything", inputSchema={"type": "object"}),
|
|
]
|
|
|
|
key_object_permission = MagicMock()
|
|
key_object_permission.mcp_servers = []
|
|
key_object_permission.mcp_access_groups = []
|
|
key_object_permission.mcp_tool_permissions = None
|
|
key_object_permission.mcp_toolsets = ["toolset-1"]
|
|
|
|
user_auth = UserAPIKeyAuth(api_key="test-key", user_id="test-user")
|
|
|
|
mock_manager = MagicMock()
|
|
mock_manager.expand_tool_permissions = MagicMock(side_effect=lambda perms: perms or {})
|
|
mock_manager.resolve_toolset_tool_permissions = AsyncMock(return_value={"server-a": ["lookup_status"]})
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_tools_from_server",
|
|
AsyncMock(return_value=upstream_tools),
|
|
)
|
|
|
|
with (
|
|
patch.object(MCPRequestHandler, "_get_key_object_permission", return_value=key_object_permission),
|
|
patch.object(MCPRequestHandler, "_get_team_object_permission", AsyncMock(return_value=None)),
|
|
patch(
|
|
"litellm.proxy.mcp_server.mcp_server_manager.global_mcp_server_manager",
|
|
mock_manager,
|
|
),
|
|
):
|
|
result = await rest_endpoints._get_tools_for_single_server(
|
|
server=stub_server,
|
|
server_auth_header=None,
|
|
raw_headers=None,
|
|
user_api_key_auth=user_auth,
|
|
)
|
|
|
|
assert [tool.name for tool in result] == ["lookup_status"]
|
|
|
|
|
|
class TestV1ResolvedOauth2Gate:
|
|
"""The REST surface must stop resolving per-user OAuth2 tokens for servers the v2 resolver owns.
|
|
|
|
``_resolve_v2_auth`` drops any Authorization built here for an ``authorization_code`` server and
|
|
injects the resolver's own token, so the v1 lookup was a DB round-trip whose result was discarded.
|
|
A server that still defers to v1 (upstream-delegated oauth2) must keep resolving, which is what
|
|
makes these assertions non-vacuous.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _oauth2_server(*, delegate_auth_to_upstream: bool) -> Any:
|
|
from litellm.proxy.mcp_server.server import MCPServer
|
|
from litellm.types.mcp import MCPTransport
|
|
|
|
return MCPServer(
|
|
server_id="oauth2-srv",
|
|
name="oauth2-srv",
|
|
url="https://upstream.example.com/mcp",
|
|
transport=MCPTransport.http,
|
|
auth_type=MCPAuth.oauth2,
|
|
delegate_auth_to_upstream=delegate_auth_to_upstream,
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"delegate_auth_to_upstream, expected_headers, expected_lookups",
|
|
[
|
|
(False, None, 0),
|
|
(True, {"Authorization": "Bearer stored-token"}, 1),
|
|
],
|
|
)
|
|
async def test_user_oauth_headers_skip_v2_owned_servers(
|
|
self, delegate_auth_to_upstream, expected_headers, expected_lookups, monkeypatch
|
|
):
|
|
from litellm.proxy.mcp_server import db as mcp_db
|
|
|
|
server = self._oauth2_server(delegate_auth_to_upstream=delegate_auth_to_upstream)
|
|
resolve_token = AsyncMock(return_value={"access_token": "stored-token"})
|
|
monkeypatch.setattr(mcp_db, "resolve_valid_user_oauth_token", resolve_token)
|
|
|
|
headers = await rest_endpoints._get_user_oauth_extra_headers(
|
|
server,
|
|
UserAPIKeyAuth(user_id="alice", api_key="sk-9876"),
|
|
prefetched_creds={"oauth2-srv": {"access_token": "stored-token"}},
|
|
)
|
|
|
|
assert headers == expected_headers
|
|
assert resolve_token.await_count == expected_lookups
|
|
|
|
def test_prefetch_preflight_only_counts_v1_resolved_servers(self, monkeypatch):
|
|
v2_owned = self._oauth2_server(delegate_auth_to_upstream=False)
|
|
v1_resolved = self._oauth2_server(delegate_auth_to_upstream=True)
|
|
v1_resolved.server_id = "delegate-srv"
|
|
registry = {"oauth2-srv": v2_owned, "delegate-srv": v1_resolved}
|
|
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_mcp_server_by_id",
|
|
lambda server_id: registry.get(server_id),
|
|
)
|
|
|
|
assert rest_endpoints._v1_resolved_oauth2_server_ids(["oauth2-srv"]) == set()
|
|
assert rest_endpoints._v1_resolved_oauth2_server_ids(["oauth2-srv", "delegate-srv"]) == {"delegate-srv"}
|
|
|
|
|
|
_CLIENT_ALLOWLIST_SETTINGS: Final[dict[str, object]] = {
|
|
"mcp_allowed_clients": [{"alias": "Antigravity CLI", "value": "antigravity-cli"}],
|
|
"litellm_jwtauth": {"mcp_client_id_jwt_field": "azp"},
|
|
"mcp_client_id_header": "x-mcp-client",
|
|
}
|
|
|
|
|
|
class TestClientAllowlistOnRestRoutes:
|
|
"""``mcp_allowed_clients`` must gate the REST tool facade exactly like the /mcp transports,
|
|
otherwise an unlisted harness can list and call tools by switching to /mcp-rest."""
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
@staticmethod
|
|
def _stub_listing(monkeypatch: pytest.MonkeyPatch) -> list[UserAPIKeyAuth]:
|
|
listed_for: list[UserAPIKeyAuth] = []
|
|
|
|
async def fake_contexts(user_api_key_auth: UserAPIKeyAuth) -> list[UserAPIKeyAuth]:
|
|
listed_for.append(user_api_key_auth)
|
|
return [user_api_key_auth]
|
|
|
|
async def fake_get_allowed_mcp_servers(
|
|
user_api_key_auth: UserAPIKeyAuth | None = None,
|
|
*,
|
|
keyless_source: bool = False,
|
|
) -> list[str]:
|
|
return []
|
|
|
|
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", _CLIENT_ALLOWLIST_SETTINGS, raising=False)
|
|
monkeypatch.setattr(rest_endpoints, "build_effective_auth_contexts", fake_contexts, raising=False)
|
|
monkeypatch.setattr(
|
|
rest_endpoints.global_mcp_server_manager,
|
|
"get_allowed_mcp_servers",
|
|
fake_get_allowed_mcp_servers,
|
|
raising=False,
|
|
)
|
|
return listed_for
|
|
|
|
@pytest.mark.parametrize(
|
|
("caller", "headers", "expected_fragment"),
|
|
(
|
|
(UserAPIKeyAuth(jwt_claims={"azp": "claude-code"}), {"x-mcp-client": "antigravity-cli"}, "'claude-code'"),
|
|
(UserAPIKeyAuth(jwt_claims={}), {"x-mcp-client": "antigravity-cli"}, "no 'azp' claim"),
|
|
(UserAPIKeyAuth(), {"x-mcp-client": "claude-code"}, "'claude-code'"),
|
|
(UserAPIKeyAuth(), {}, "no 'x-mcp-client' header"),
|
|
),
|
|
)
|
|
async def test_tools_list_rejects_unlisted_clients_before_resolving_servers(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
caller: UserAPIKeyAuth,
|
|
headers: dict[str, str],
|
|
expected_fragment: str,
|
|
) -> None:
|
|
listed_for: Final = self._stub_listing(monkeypatch)
|
|
request: Final = _build_request(headers, path="/mcp-rest/tools/list", method="GET")
|
|
|
|
with pytest.raises(HTTPException) as denied:
|
|
await rest_endpoints.list_tool_rest_api(
|
|
request, server_id=None, mcp_server_name=None, toolset_name=None, user_api_key_dict=caller
|
|
)
|
|
|
|
assert denied.value.status_code == 403
|
|
assert denied.value.detail["error"] == "Forbidden"
|
|
assert expected_fragment in denied.value.detail["details"]
|
|
assert "mcp_allowed_clients" in denied.value.detail["details"]
|
|
assert listed_for == []
|
|
|
|
@pytest.mark.parametrize(
|
|
("caller", "headers"),
|
|
(
|
|
(UserAPIKeyAuth(jwt_claims={"azp": "antigravity-cli"}), {"x-mcp-client": "claude-code"}),
|
|
(UserAPIKeyAuth(), {"x-mcp-client": "antigravity-cli"}),
|
|
),
|
|
)
|
|
async def test_tools_list_admits_listed_clients(
|
|
self, monkeypatch: pytest.MonkeyPatch, caller: UserAPIKeyAuth, headers: dict[str, str]
|
|
) -> None:
|
|
listed_for: Final = self._stub_listing(monkeypatch)
|
|
request: Final = _build_request(headers, path="/mcp-rest/tools/list", method="GET")
|
|
|
|
result: Final = await rest_endpoints.list_tool_rest_api(
|
|
request, server_id=None, mcp_server_name=None, toolset_name=None, user_api_key_dict=caller
|
|
)
|
|
|
|
assert result["tools"] == []
|
|
assert listed_for == [caller]
|
|
|
|
async def test_dashboard_session_is_not_treated_as_a_client_application(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
from litellm.constants import UI_SESSION_TOKEN_TEAM_ID
|
|
|
|
listed_for: Final = self._stub_listing(monkeypatch)
|
|
session: Final = UserAPIKeyAuth(team_id=UI_SESSION_TOKEN_TEAM_ID, user_id="admin-user", user_role="proxy_admin")
|
|
request: Final = _build_request(path="/mcp-rest/tools/list", method="GET")
|
|
|
|
result: Final = await rest_endpoints.list_tool_rest_api(
|
|
request, server_id=None, mcp_server_name=None, toolset_name=None, user_api_key_dict=session
|
|
)
|
|
|
|
assert result["tools"] == []
|
|
assert listed_for == [session]
|
|
|
|
async def test_tools_call_rejects_unlisted_clients_before_reading_the_body(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", _CLIENT_ALLOWLIST_SETTINGS, raising=False)
|
|
acting: Final = AsyncMock()
|
|
monkeypatch.setattr(rest_endpoints, "acting_user_auth", acting, raising=False)
|
|
request: Final = _build_request(
|
|
{"x-mcp-client": "antigravity-cli"},
|
|
path="/mcp-rest/tools/call",
|
|
method="POST",
|
|
json_body={"server_id": "server-1", "name": "demo-tool", "arguments": {}},
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as denied:
|
|
await rest_endpoints.call_tool_rest_api(
|
|
request, user_api_key_dict=UserAPIKeyAuth(jwt_claims={"azp": "claude-code"})
|
|
)
|
|
|
|
assert denied.value.status_code == 403
|
|
assert denied.value.detail["error"] == "Forbidden"
|
|
assert "'claude-code'" in denied.value.detail["details"]
|
|
acting.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("revision", ("auto", "2024-11-05", "2025-06-18"))
|
|
async def test_preview_client_honors_protocol_metadata(revision: MCPUpstreamProtocol) -> None:
|
|
from litellm.mcp_client.client import MCPClient
|
|
|
|
payload: Final = NewMCPServerRequest(
|
|
server_name="preview",
|
|
url="http://127.0.0.1:9/mcp",
|
|
transport="http",
|
|
auth_type=MCPAuth.none,
|
|
mcp_info={"protocol_version": revision},
|
|
)
|
|
|
|
async def inspect_client(client: MCPClient) -> dict[str, str]:
|
|
return {"protocol_version": client.protocol_version}
|
|
|
|
result: Final = await rest_endpoints._execute_with_mcp_client(payload, inspect_client)
|
|
assert result == {"protocol_version": revision}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("auth_type", (MCPAuth.none, MCPAuth.bearer_token, MCPAuth.oauth2))
|
|
@pytest.mark.parametrize(
|
|
("metadata", "expected"),
|
|
(
|
|
(None, "2025-11-25"),
|
|
({}, "2025-11-25"),
|
|
({"description": "edited"}, "2025-11-25"),
|
|
({"protocol_version": "auto"}, "auto"),
|
|
({"protocol_version": "2024-11-05"}, "2024-11-05"),
|
|
({"protocol_version": "2025-06-18"}, "2025-06-18"),
|
|
),
|
|
)
|
|
async def test_saved_preview_protocol_omission_and_explicit_edits(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
auth_type: MCPAuth,
|
|
metadata: dict[str, str] | None,
|
|
expected: MCPUpstreamProtocol,
|
|
) -> None:
|
|
from starlette.datastructures import Headers
|
|
|
|
from litellm.mcp_client.client import MCPClient
|
|
from litellm.proxy.management_endpoints import mcp_management_endpoints
|
|
from litellm.proxy.mcp_server.mcp_server_manager import MCPServerManager
|
|
|
|
saved: Final = MCPServer(
|
|
server_id="saved-preview",
|
|
name="preview",
|
|
url="https://example.com/mcp",
|
|
transport="http",
|
|
auth_type=auth_type,
|
|
protocol_version="2025-11-25",
|
|
authentication_token="stored-token",
|
|
authorization_url="https://example.com/authorize",
|
|
token_url="https://example.com/token",
|
|
)
|
|
manager: Final = MCPServerManager()
|
|
manager.registry = {saved.server_id: saved}
|
|
monkeypatch.setattr(rest_endpoints, "global_mcp_server_manager", manager)
|
|
monkeypatch.setattr(mcp_management_endpoints, "global_mcp_server_manager", manager)
|
|
payload: Final = NewMCPServerRequest(
|
|
server_id=saved.server_id,
|
|
server_name=saved.name,
|
|
url=saved.url,
|
|
transport="http",
|
|
auth_type=auth_type,
|
|
mcp_info=metadata,
|
|
authorization_url=saved.authorization_url,
|
|
token_url=saved.token_url,
|
|
)
|
|
staged: Final = rest_endpoints._stage_server_test(
|
|
payload, Headers({"x-litellm-api-key": "sk-admin", "authorization": "Bearer preview-token"})
|
|
)
|
|
|
|
async def inspect_client(client: MCPClient) -> dict[str, str]:
|
|
return {"protocol_version": client.protocol_version}
|
|
|
|
result: Final = await rest_endpoints._execute_with_mcp_client(
|
|
staged.request,
|
|
inspect_client,
|
|
mcp_auth_header=staged.mcp_auth_header,
|
|
oauth2_headers=staged.oauth2_headers,
|
|
)
|
|
assert result == {"protocol_version": expected}
|
|
assert saved.protocol_version == "2025-11-25"
|
|
assert payload.mcp_info == metadata
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("virtual", [False, True])
|
|
@pytest.mark.parametrize(
|
|
"bearer,claims,expected",
|
|
[
|
|
("sk-guardrail-admission", None, None),
|
|
("caller.jwt.assertion", {"sub": "caller"}, "caller.jwt.assertion"),
|
|
("upstream-token", None, "upstream-token"),
|
|
],
|
|
)
|
|
async def test_rest_guardrail_bearer_excludes_admission_key(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
virtual: bool,
|
|
bearer: str,
|
|
claims: dict[str, str] | None,
|
|
expected: str | None,
|
|
) -> None:
|
|
import litellm
|
|
from litellm.caching.dual_cache import DualCache
|
|
from litellm.integrations.custom_logger import CustomLogger
|
|
from litellm.proxy.mcp_server.tool_registry import global_mcp_tool_registry
|
|
|
|
observed: Final[asyncio.Queue[object]] = asyncio.Queue()
|
|
|
|
class CaptureBearer(CustomLogger):
|
|
async def async_pre_call_hook(
|
|
self,
|
|
user_api_key_dict: UserAPIKeyAuth,
|
|
cache: DualCache,
|
|
data: dict[str, object],
|
|
call_type: str,
|
|
) -> None:
|
|
if call_type == "call_mcp_tool" and "incoming_bearer_token" in data:
|
|
observed.put_nowait(data["incoming_bearer_token"])
|
|
|
|
async def echo() -> str:
|
|
return "guarded-result"
|
|
|
|
auth: Final = UserAPIKeyAuth(
|
|
api_key="sk-guardrail-admission",
|
|
jwt_claims=claims,
|
|
object_permission={
|
|
"object_permission_id": "guarded",
|
|
"mcp_servers": ["guarded"],
|
|
"mcp_tool_search_enabled": virtual,
|
|
},
|
|
)
|
|
managed: Final = MCPServer(
|
|
server_id="guarded",
|
|
name="guarded",
|
|
transport=MCPTransport.http,
|
|
spec_path="/catalog.yaml",
|
|
allow_all_keys=True,
|
|
)
|
|
monkeypatch.setitem(rest_endpoints.global_mcp_server_manager.registry, managed.server_id, managed)
|
|
monkeypatch.setattr(litellm, "callbacks", [CaptureBearer()])
|
|
global_mcp_tool_registry.register_tool(
|
|
name="guarded-echo",
|
|
description="Echo",
|
|
input_schema={"type": "object"},
|
|
handler=echo,
|
|
)
|
|
headers: Final = {
|
|
"authorization": f"Bearer {bearer}",
|
|
**({} if claims else {"x-litellm-api-key": "sk-guardrail-admission"}),
|
|
}
|
|
payload: Final = {
|
|
"server_id": "guarded",
|
|
"name": "mcp_tool_call" if virtual else "guarded-echo",
|
|
"arguments": {"tool_name": "guarded-echo", "arguments": {}} if virtual else {},
|
|
}
|
|
try:
|
|
result: Final = await rest_endpoints.call_tool_rest_api(
|
|
_build_request(headers, path="/mcp-rest/tools/call", json_body=payload),
|
|
user_api_key_dict=auth,
|
|
)
|
|
assert result.is_error is False
|
|
assert observed.qsize() == 1
|
|
assert observed.get_nowait() == expected
|
|
if claims is None:
|
|
list_headers: Final = rest_endpoints._extract_mcp_headers_from_request(
|
|
_build_request(headers),
|
|
auth,
|
|
)[2]
|
|
authorization: Final = list_headers.get("authorization", "")
|
|
assert (authorization.removeprefix("Bearer ") or None) == expected
|
|
finally:
|
|
global_mcp_tool_registry.unregister_tools_with_prefix("guarded-")
|