mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-05 02:41:56 +00:00
* test(integration): drop the contracts.json manifest and the covers requirement Groups live as a GROUPS literal in run.py, the browser expectations move next to the browser tests, and the runner fails only on pytest failure, collection errors or a selected file that collects zero tests. The covers marker stays registered for the existing tests but is no longer checked. The mcp directory gets its own group Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci(integration): run mcp as its own shard with xdist and a peer proxy Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci(integration): INTEGRATION_COVERAGE=1 runs the proxy under coverage for the MCP modules The mcp shard sets it. The proxy and its peer start under coverage run in parallel mode, get SIGTERM after the tests so coverage flushes, and the combined text and HTML reports land in the suite results that CircleCI already stores as artifacts Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci(integration): let the test proxy flush coverage when uvicorn re-raises SIGTERM Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): add SSE, stdio, scripted, OpenAPI and OAuth 2.1 MCP peer doubles Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): add MCP transport and access-control matrices Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): add MCP credential and OAuth flow coverage Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): add MCP LLM endpoint, accounting, guardrail, resilience and lifecycle coverage Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): stop the same-URL grant test from counting a late initialize as a leaked call and satisfy the test-tree lint Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): assert the REST denied-server listing is refused or empty Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): pin the REST denied-server listing to 403 access_denied Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yuneng <yuneng@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
363 lines
17 KiB
Python
363 lines
17 KiB
Python
import base64
|
|
import hashlib
|
|
import secrets
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from typing import Final
|
|
from urllib.parse import parse_qs, urlsplit
|
|
|
|
import httpx
|
|
import pytest
|
|
from integration._support.client import Gateway, eventually
|
|
from integration._support.database import read_rows
|
|
from integration._support.mcp import (
|
|
ENTRY_POINTS,
|
|
EntryPoint,
|
|
McpCaller,
|
|
McpPeer,
|
|
call_tool,
|
|
mcp_peer,
|
|
register_mcp,
|
|
tool_calls,
|
|
)
|
|
from integration._support.oauth_server import AuthorizationServer, oauth_server
|
|
|
|
ADD: Final = {"a": 2, "b": 3}
|
|
CLIENT_REDIRECT: Final = "http://127.0.0.1:9/cb"
|
|
ACCEPT: Final = {"Accept": "application/json, text/event-stream"}
|
|
|
|
|
|
def _base(gateway: Gateway) -> str:
|
|
return str(gateway.client.base_url).rstrip("/")
|
|
|
|
|
|
def _authorizations(peer: McpPeer) -> tuple[bytes | None, ...]:
|
|
return tuple(
|
|
value if isinstance(value := call["headers"].get(b"authorization"), bytes) else None
|
|
for call in tool_calls(peer.drain())
|
|
if isinstance(call["headers"], dict)
|
|
)
|
|
|
|
|
|
def _issued_token(issued: dict[str, object]) -> str:
|
|
token: Final = issued["access_token"]
|
|
assert isinstance(token, str)
|
|
return token
|
|
|
|
|
|
def _register_oauth(scenario, peer: McpPeer, auth: AuthorizationServer, alias: str, **fields: object) -> str:
|
|
return register_mcp(
|
|
scenario,
|
|
peer,
|
|
alias,
|
|
issuer=auth.issuer,
|
|
authorization_url=auth.issuer + "/authorize",
|
|
token_url=auth.issuer + "/token",
|
|
registration_url=auth.issuer + "/register",
|
|
**fields,
|
|
)
|
|
|
|
|
|
def _plaintext_credential_rows(identity: str, secret: str) -> list[dict[str, object]]:
|
|
return read_rows(
|
|
'SELECT server_id FROM "LiteLLM_MCPServerTable" WHERE server_id = %s AND credentials::text LIKE %s',
|
|
(identity, f"%{secret}%"),
|
|
)
|
|
|
|
|
|
def test_client_credentials_token_is_minted_once_and_sent_as_bearer(gateway: Gateway) -> None:
|
|
with mcp_peer() as peer, oauth_server() as auth, gateway.scenario() as scenario:
|
|
alias: Final = "cc" + uuid.uuid4().hex[:8]
|
|
secret: Final = "cc-secret-" + uuid.uuid4().hex
|
|
identity: Final = _register_oauth(
|
|
scenario,
|
|
peer,
|
|
auth,
|
|
alias,
|
|
auth_type="oauth2",
|
|
oauth2_flow="client_credentials",
|
|
credentials={"client_id": "cc-client", "client_secret": secret, "scopes": ["tools.call"]},
|
|
)
|
|
key: Final = scenario.key(object_permission={"mcp_servers": [identity]})
|
|
peer.drain()
|
|
for _ in range(2):
|
|
response: Final = call_tool(gateway, key, identity, f"{alias}-add", ADD)
|
|
assert response.status_code == 200, response.text
|
|
minted: Final = auth.token_requests()
|
|
assert [request["grant_type"] for request in minted] == ["client_credentials"], minted
|
|
assert minted[0]["client_id"] == "cc-client" and minted[0]["client_secret"] == secret
|
|
assert minted[0]["scope"] == "tools.call"
|
|
seen: Final = _authorizations(peer)
|
|
assert len(seen) == 2 and len(set(seen)) == 1, seen
|
|
assert seen[0] is not None and auth.is_live(seen[0].decode().removeprefix("Bearer ")), seen
|
|
assert secret.encode() not in (seen[0] or b""), "client secret forwarded to the peer"
|
|
assert _plaintext_credential_rows(identity, secret) == []
|
|
|
|
|
|
def test_rotating_the_client_secret_forces_a_fresh_token(gateway: Gateway) -> None:
|
|
with mcp_peer() as peer, oauth_server() as auth, gateway.scenario() as scenario:
|
|
alias: Final = "cc" + uuid.uuid4().hex[:8]
|
|
identity: Final = _register_oauth(
|
|
scenario,
|
|
peer,
|
|
auth,
|
|
alias,
|
|
auth_type="oauth2",
|
|
oauth2_flow="client_credentials",
|
|
credentials={"client_id": "cc-client", "client_secret": "first-" + uuid.uuid4().hex},
|
|
)
|
|
key: Final = scenario.key(object_permission={"mcp_servers": [identity]})
|
|
assert call_tool(gateway, key, identity, f"{alias}-add", ADD).status_code == 200
|
|
before: Final = _authorizations(peer)
|
|
auth.drain()
|
|
rotated: Final = "second-" + uuid.uuid4().hex
|
|
edited: Final = gateway.request(
|
|
"PUT",
|
|
"/v1/mcp/server",
|
|
{"server_id": identity, "credentials": {"client_id": "cc-client", "client_secret": rotated}},
|
|
)
|
|
assert edited.status_code == 202, edited.text
|
|
after: Final = eventually(
|
|
lambda: (call_tool(gateway, key, identity, f"{alias}-add", ADD).status_code, _authorizations(peer)),
|
|
lambda value: value[0] == 200 and value[1] != () and value[1][-1] not in before,
|
|
)
|
|
assert [request["client_secret"] for request in auth.token_requests()][-1] == rotated
|
|
assert after[1][-1] not in before
|
|
|
|
|
|
def test_token_exchange_swaps_the_callers_subject_token_and_never_forwards_it(gateway: Gateway) -> None:
|
|
with mcp_peer() as peer, oauth_server() as auth, gateway.scenario() as scenario:
|
|
alias: Final = "te" + uuid.uuid4().hex[:8]
|
|
identity: Final = register_mcp(
|
|
scenario,
|
|
peer,
|
|
alias,
|
|
auth_type="oauth2_token_exchange",
|
|
token_exchange_endpoint=auth.issuer + "/token",
|
|
audience="urn:integration:peer",
|
|
credentials={"client_id": "te-client", "client_secret": "te-secret"},
|
|
)
|
|
key: Final = scenario.key(object_permission={"mcp_servers": [identity]})
|
|
subject: Final = "subject-" + uuid.uuid4().hex
|
|
peer.drain()
|
|
auth.drain()
|
|
response: Final = gateway.client.post(
|
|
"/mcp-rest/tools/call",
|
|
headers={"x-litellm-api-key": key, "Authorization": f"Bearer {subject}"},
|
|
json={"name": f"{alias}-add", "arguments": ADD, "server_id": identity},
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
exchanged: Final = auth.token_requests()
|
|
assert len(exchanged) == 1, exchanged
|
|
assert exchanged[0]["grant_type"] == "urn:ietf:params:oauth:grant-type:token-exchange"
|
|
assert exchanged[0]["subject_token"] == subject
|
|
assert exchanged[0]["audience"] == "urn:integration:peer"
|
|
seen: Final = _authorizations(peer)
|
|
assert len(seen) == 1 and seen[0] is not None and subject.encode() not in seen[0], seen
|
|
assert seen[0].startswith(b"Bearer ") and auth.is_live(seen[0].decode().removeprefix("Bearer "))
|
|
|
|
|
|
def test_token_exchange_without_a_subject_token_is_rejected_before_any_upstream_request(gateway: Gateway) -> None:
|
|
with mcp_peer() as peer, oauth_server() as auth, gateway.scenario() as scenario:
|
|
alias: Final = "te" + uuid.uuid4().hex[:8]
|
|
identity: Final = register_mcp(
|
|
scenario,
|
|
peer,
|
|
alias,
|
|
auth_type="oauth2_token_exchange",
|
|
token_exchange_endpoint=auth.issuer + "/token",
|
|
credentials={"client_id": "te-client", "client_secret": "te-secret"},
|
|
)
|
|
key: Final = scenario.key(object_permission={"mcp_servers": [identity]})
|
|
peer.drain()
|
|
auth.drain()
|
|
response: Final = call_tool(gateway, key, identity, f"{alias}-add", ADD)
|
|
assert tool_calls(peer.drain()) == ()
|
|
assert auth.token_requests() == ()
|
|
if response.status_code == 500:
|
|
pytest.skip("BUG: /mcp-rest/tools/call without a subject token on a token-exchange server returns 500")
|
|
assert response.status_code == 401, response.text
|
|
|
|
|
|
@pytest.mark.parametrize("entry", ENTRY_POINTS)
|
|
def test_delegated_auth_forwards_the_callers_bearer_untouched(gateway: Gateway, entry: EntryPoint) -> None:
|
|
with mcp_peer() as peer, gateway.scenario() as scenario:
|
|
alias: Final = "dl" + uuid.uuid4().hex[:8]
|
|
identity: Final = register_mcp(scenario, peer, alias, auth_type="oauth_delegate")
|
|
key: Final = scenario.key(object_permission={"mcp_servers": [identity]})
|
|
token: Final = "user-" + uuid.uuid4().hex
|
|
caller: Final = McpCaller(gateway, key, entry, alias, headers={"Authorization": f"Bearer {token}"})
|
|
peer.drain()
|
|
outcome: Final = caller.call(f"{alias}-add", ADD, identity if entry in ("mcp", "root", "sse", "rest") else None)
|
|
assert outcome.ok, outcome.raw
|
|
seen: Final = _authorizations(peer)
|
|
if seen == (None,) and entry == "rest":
|
|
pytest.skip("BUG: /mcp-rest/tools/call drops the caller's Authorization on an oauth_delegate server")
|
|
assert seen == (f"Bearer {token}".encode(),), seen
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _Pkce:
|
|
verifier: str
|
|
|
|
@property
|
|
def challenge(self) -> str:
|
|
digest: Final = hashlib.sha256(self.verifier.encode()).digest()
|
|
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
|
|
|
|
|
|
def _authorize_through_gateway(
|
|
gateway: Gateway, auth: AuthorizationServer, alias: str, key: str, client_id: str, pkce: _Pkce
|
|
) -> str:
|
|
started: Final = gateway.client.get(
|
|
f"/{alias}/authorize",
|
|
params={
|
|
"client_id": client_id,
|
|
"redirect_uri": CLIENT_REDIRECT,
|
|
"response_type": "code",
|
|
"state": "client-state",
|
|
"code_challenge": pkce.challenge,
|
|
"code_challenge_method": "S256",
|
|
"scope": "tools.call",
|
|
},
|
|
headers={"x-litellm-api-key": key},
|
|
)
|
|
assert started.status_code in (302, 307), started.text
|
|
upstream: Final = started.headers["location"]
|
|
assert upstream.startswith(auth.issuer + "/authorize"), upstream
|
|
upstream_query: Final = parse_qs(urlsplit(upstream).query)
|
|
assert upstream_query["code_challenge_method"] == ["S256"]
|
|
assert upstream_query["redirect_uri"] != [CLIENT_REDIRECT], "client redirect relayed upstream"
|
|
consent: Final = httpx.get(upstream, follow_redirects=False)
|
|
assert consent.status_code == 302, consent.text
|
|
callback: Final = consent.headers["location"]
|
|
assert callback.startswith(_base(gateway)), callback
|
|
returned: Final = gateway.client.get(
|
|
callback.removeprefix(_base(gateway)), headers={"x-litellm-api-key": key}, cookies=started.cookies
|
|
)
|
|
assert returned.status_code == 302, returned.text
|
|
final: Final = parse_qs(urlsplit(returned.headers["location"]).query)
|
|
assert returned.headers["location"].startswith(CLIENT_REDIRECT)
|
|
assert final["state"] == ["client-state"], final
|
|
return final["code"][0]
|
|
|
|
|
|
def _redeem(gateway: Gateway, alias: str, key: str, client_id: str, code: str, pkce: _Pkce) -> httpx.Response:
|
|
return gateway.client.post(
|
|
f"/{alias}/token",
|
|
headers={"x-litellm-api-key": key},
|
|
data={
|
|
"grant_type": "authorization_code",
|
|
"code": code,
|
|
"code_verifier": pkce.verifier,
|
|
"client_id": client_id,
|
|
"redirect_uri": CLIENT_REDIRECT,
|
|
},
|
|
)
|
|
|
|
|
|
def test_per_user_authorization_code_with_pkce_binds_the_token_to_the_authorizing_user(gateway: Gateway) -> None:
|
|
with mcp_peer() as peer, oauth_server() as auth, gateway.scenario() as scenario:
|
|
alias: Final = "ac" + uuid.uuid4().hex[:8]
|
|
identity: Final = _register_oauth(
|
|
scenario,
|
|
peer,
|
|
auth,
|
|
alias,
|
|
auth_type="oauth2",
|
|
oauth2_flow="authorization_code",
|
|
credentials={"client_id": "ac-client", "client_secret": "ac-secret", "scopes": ["tools.call"]},
|
|
)
|
|
owner: Final = scenario.key(user_id=scenario.user(), object_permission={"mcp_servers": [identity]})
|
|
stranger: Final = scenario.key(user_id=scenario.user(), object_permission={"mcp_servers": [identity]})
|
|
anonymous: Final = gateway.client.post(
|
|
f"/{alias}/mcp", headers=ACCEPT, json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}
|
|
)
|
|
assert anonymous.status_code == 401, anonymous.text
|
|
metadata_url: Final = anonymous.headers["www-authenticate"].split('resource_metadata="')[1].rstrip('"')
|
|
metadata: Final = httpx.get(metadata_url)
|
|
assert metadata.status_code == 200 and metadata.json()["resource"] == f"{_base(gateway)}/{alias}/mcp"
|
|
registered: Final = gateway.client.post(
|
|
f"/{alias}/register", json={"redirect_uris": [CLIENT_REDIRECT], "client_name": "integration"}
|
|
)
|
|
assert registered.status_code in (200, 201), registered.text
|
|
client_id: Final = registered.json()["client_id"]
|
|
pkce: Final = _Pkce(secrets.token_urlsafe(32))
|
|
code: Final = _authorize_through_gateway(gateway, auth, alias, owner, client_id, pkce)
|
|
wrong_verifier: Final = _redeem(gateway, alias, owner, client_id, code, _Pkce("wrong-" + pkce.verifier))
|
|
assert wrong_verifier.status_code == 400, wrong_verifier.text
|
|
assert tool_calls(peer.drain()) == ()
|
|
code2: Final = _authorize_through_gateway(gateway, auth, alias, owner, client_id, pkce)
|
|
redeemed: Final = _redeem(gateway, alias, owner, client_id, code2, pkce)
|
|
assert redeemed.status_code == 200, redeemed.text
|
|
issued: Final = redeemed.json()
|
|
assert auth.is_live(_issued_token(issued))
|
|
reused: Final = _redeem(gateway, alias, owner, client_id, code2, pkce)
|
|
assert reused.status_code == 400, reused.text
|
|
peer.drain()
|
|
as_owner: Final = call_tool(gateway, owner, identity, f"{alias}-add", ADD)
|
|
assert as_owner.status_code == 200, as_owner.text
|
|
assert _authorizations(peer) == (f"Bearer {_issued_token(issued)}".encode(),)
|
|
as_stranger: Final = call_tool(gateway, stranger, identity, f"{alias}-add", ADD)
|
|
assert as_stranger.status_code == 401, as_stranger.text
|
|
assert tool_calls(peer.drain()) == ()
|
|
upstream_only: Final = gateway.client.post(
|
|
f"/{alias}/mcp",
|
|
headers={**ACCEPT, "Authorization": f"Bearer {_issued_token(issued)}"},
|
|
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}},
|
|
)
|
|
assert upstream_only.status_code == 401, upstream_only.text
|
|
assert tool_calls(peer.drain()) == ()
|
|
refreshed: Final = gateway.client.post(
|
|
f"/{alias}/token",
|
|
headers={"x-litellm-api-key": owner},
|
|
data={"grant_type": "refresh_token", "refresh_token": issued["refresh_token"], "client_id": client_id},
|
|
)
|
|
assert refreshed.status_code == 200, refreshed.text
|
|
assert refreshed.json()["access_token"] != issued["access_token"]
|
|
assert (
|
|
read_rows(
|
|
'SELECT 1 FROM "LiteLLM_MCPServerTable" WHERE server_id = %s AND credentials::text LIKE %s',
|
|
(identity, "%ac-secret%"),
|
|
)
|
|
== []
|
|
)
|
|
|
|
|
|
def test_authorization_request_without_pkce_is_refused_before_reaching_the_authorization_server(
|
|
gateway: Gateway,
|
|
) -> None:
|
|
with mcp_peer() as peer, oauth_server() as auth, gateway.scenario() as scenario:
|
|
alias: Final = "br" + uuid.uuid4().hex[:8]
|
|
identity: Final = _register_oauth(scenario, peer, auth, alias, auth_type="oauth_delegate", dcr_bridge=True)
|
|
key: Final = scenario.key(user_id=scenario.user(), object_permission={"mcp_servers": [identity]})
|
|
auth.drain()
|
|
refused: Final = gateway.client.get(
|
|
f"/{alias}/authorize",
|
|
params={"client_id": "c", "redirect_uri": CLIENT_REDIRECT, "response_type": "code", "state": "s"},
|
|
headers={"x-litellm-api-key": key},
|
|
)
|
|
assert refused.status_code == 400, refused.text
|
|
assert "PKCE" in refused.text
|
|
assert auth.drain() == ()
|
|
|
|
|
|
def test_dcr_bridge_relays_client_registration_and_advertises_gateway_endpoints(gateway: Gateway) -> None:
|
|
with mcp_peer() as peer, oauth_server() as auth, gateway.scenario() as scenario:
|
|
alias: Final = "dcr" + uuid.uuid4().hex[:8]
|
|
_register_oauth(scenario, peer, auth, alias, auth_type="oauth_delegate", dcr_bridge=True)
|
|
auth.drain()
|
|
registered: Final = gateway.client.post(
|
|
f"/{alias}/register", json={"redirect_uris": [CLIENT_REDIRECT], "client_name": "integration"}
|
|
)
|
|
assert registered.status_code in (200, 201), registered.text
|
|
assert registered.json()["client_id"].startswith("dcr-"), registered.text
|
|
assert [(request.method, urlsplit(request.target).path) for request in auth.drain()] == [("POST", "/register")]
|
|
resource: Final = gateway.client.get(f"/.well-known/oauth-protected-resource/{alias}/mcp")
|
|
assert resource.status_code == 200, resource.text
|
|
assert resource.json()["authorization_servers"] == [f"{_base(gateway)}/{alias}"]
|
|
issuer: Final = gateway.client.get(f"/.well-known/oauth-authorization-server/{alias}/mcp")
|
|
assert issuer.status_code == 200, issuer.text
|
|
assert issuer.json()["authorization_endpoint"] == f"{_base(gateway)}/{alias}/authorize"
|
|
assert issuer.json()["token_endpoint"] == f"{_base(gateway)}/{alias}/token"
|
|
assert "S256" in issuer.json()["code_challenge_methods_supported"]
|