From 4236a43aa7b27531cf7d6789673a568b9c3f7533 Mon Sep 17 00:00:00 2001 From: yucheng Date: Wed, 30 Sep 2026 01:57:39 +0000 Subject: [PATCH] feat(mcp): challenge opaque caller bearers at connect and move Agent 365 sign-in onto the fixed production constants Rework the Agent 365 sign-in provider for the guardrail shape 43189 landed on main: the OBO scope and resource come from the fixed AGENT_365_PROD_* constants instead of the removed resource_app_id/api_base fields, and the exchange runs through the shared TokenExchanger so the connect preflight and the tool call reuse one cached token per caller assertion. A present but non-JWS bearer is now rejected in preflight_caller_sign_in, so the connect answers 401 with the RFC 9728 challenge instead of letting the call reach tools/call and lose WWW-Authenticate in the JSON-RPC error. The OBO-only tool-call challenge in operations.py stays narrowed to token_exchange servers. Immutable rewrites (tuple, MappingProxyType, explicit None checks) keep the LIT002 total within the budget without a mutable-ok Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../mcp_server/mcp_server_manager.py | 5 +- .../_experimental/mcp_server/operations.py | 10 +- .../proxy/_experimental/mcp_server/server.py | 4 +- .../guardrail_hooks/agent_365/agent_365.py | 23 ++- litellm/proxy/utils.py | 1 + .../mcp/test_mcp_agent_365_guardrail.py | 10 +- .../mcp/test_mcp_caller_sign_in.py | 140 ++++++++---------- .../mcp_server/test_discoverable_endpoints.py | 16 +- .../guardrail_hooks/test_agent_365.py | 56 +++++-- .../utils/proxy_logging/test_mcp_bridging.py | 5 + 10 files changed, 146 insertions(+), 124 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 30e9d7f833e..287913d8eb7 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -5971,10 +5971,7 @@ class MCPServerManager: if proxy_logging_obj is None: return hook_result - inbound_authorization: Final = next( - (v for k, v in (raw_headers or {}).items() if isinstance(k, str) and k.lower() == "authorization"), - "", - ) + inbound_authorization: Final = _raw_header_value(raw_headers, "authorization") or "" incoming_bearer_token: Final = ( inbound_authorization[len("bearer ") :] if inbound_authorization.lower().startswith("bearer ") else None ) diff --git a/litellm/proxy/_experimental/mcp_server/operations.py b/litellm/proxy/_experimental/mcp_server/operations.py index abc7c52f5a7..50101975dca 100644 --- a/litellm/proxy/_experimental/mcp_server/operations.py +++ b/litellm/proxy/_experimental/mcp_server/operations.py @@ -1766,13 +1766,11 @@ def _challenge_missing_token_exchange_subject( The listing that fills a cold catalog absorbs the upstream 401 by design, so without this check a missing subject surfaces as an unknown-tool error instead of the challenge the warm path already raises. Gated to servers the key may reach so an unauthorized caller - learns nothing about the catalog. + learns nothing about the catalog. Guardrail-only sign-in is challenged at connect instead: a + tool call's JSON-RPC error drops ``WWW-Authenticate``, so the guardrail's own rejection is + the more useful answer there. """ - from litellm.proxy._experimental.mcp_server.caller_sign_in import ( - caller_sign_in_for, # noqa: PLC0415 # lazy: caller_sign_in pulls the proxy graph - ) - - if server is None or caller_sign_in_for(server, user_api_key_auth) is None: + if server is None or server.auth_type != MCPAuth.oauth2_token_exchange: return if requested_server is not None and requested_server.server_id != server.server_id: return diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 9763c20cd4d..cd1b0f9ddb6 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -1589,7 +1589,7 @@ if MCP_AVAILABLE: ) -> bool: """Sign-in challenges are issued only on a single-server connect the key's grant admits, so a key without access gets the grant's 403 instead of a sign-in it could not use.""" - if len(mcp_servers or ()) != 1: + if mcp_servers is None or len(mcp_servers) != 1: return False allowed: Final = await operations._get_allowed_mcp_servers( user_api_key_auth=user_api_key_auth, mcp_servers=mcp_servers, client_ip=client_ip @@ -1746,7 +1746,7 @@ if MCP_AVAILABLE: await operations._get_allowed_mcp_servers( user_api_key_auth=user_api_key_auth, mcp_servers=mcp_servers, client_ip=client_ip ) - if server and len(mcp_servers or ()) == 1 + if server and mcp_servers is not None and len(mcp_servers) == 1 else () ) if ( diff --git a/litellm/proxy/guardrails/guardrail_hooks/agent_365/agent_365.py b/litellm/proxy/guardrails/guardrail_hooks/agent_365/agent_365.py index 7208041f4da..9ffd1111d62 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/agent_365/agent_365.py +++ b/litellm/proxy/guardrails/guardrail_hooks/agent_365/agent_365.py @@ -100,6 +100,15 @@ class _EvaluateResponse(TypedDict, total=False): correlationId: ReadOnly[str] +class _AdmissionMetadata(TypedDict): + user_api_key_metadata: ReadOnly[dict | None] + user_api_key_team_metadata: ReadOnly[dict | None] + + +class _AdmissionProbe(TypedDict): + metadata: ReadOnly[_AdmissionMetadata] + + class _ToolReference(BaseModel): model_config = ConfigDict(frozen=True) @@ -447,10 +456,8 @@ class Agent365Guardrail(CustomGuardrail): if not (self.default_on and server.keeps_caller_authorization): return None if user_api_key_auth is not None: - probe: Final[ - dict[str, Mapping[str, object]] - ] = { # mutable-ok: should_run_guardrail takes a mutable data dict # pyright: ignore[reportUnknownVariableType] # UserAPIKeyAuth metadata dicts are untyped - "metadata": { # mutable-ok: should_run_guardrail takes a mutable data dict + probe: Final[_AdmissionProbe] = { + "metadata": { "user_api_key_metadata": user_api_key_auth.metadata, # pyright: ignore[reportUnknownMemberType] # UserAPIKeyAuth.metadata is a raw dict "user_api_key_team_metadata": user_api_key_auth.team_metadata, # pyright: ignore[reportUnknownMemberType] # UserAPIKeyAuth.team_metadata is a raw dict } @@ -477,12 +484,12 @@ class Agent365Guardrail(CustomGuardrail): async def preflight_caller_sign_in( self, server: MCPServer, user_api_key_auth: "UserAPIKeyAuth | None", subject_token: str ) -> CallerSignInPreflight: - """The connect-time check the preemptive gate runs: a bearer Entra rejects gets the sign-in - challenge here, where ``WWW-Authenticate`` still reaches the client, instead of surfacing as a - JSON-RPC error on every tools/call. ``subject_token=None`` stays the challenge gate's job.""" + """The connect-time check the preemptive gate runs: a bearer Entra rejects, or one it could never + accept, gets the sign-in challenge here, where ``WWW-Authenticate`` still reaches the client, instead of + surfacing as a JSON-RPC error on every tools/call. ``subject_token=None`` stays the challenge gate's job.""" assertion: Final = entra_assertion(subject_token) if assertion is None: - return SignedIn() + return Rejected(detail="the caller's bearer is not an Entra token; sign in with Entra and retry") try: exchange_result: Final = await self._exchange_caller_assertion(assertion) except (httpx.HTTPError, LitellmTimeout, TimeoutError) as exc: diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 8a325eba699..8406d021ad3 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -1519,6 +1519,7 @@ class ProxyLogging: # (e.g. MCPJWTSigner) to independently verify the caller's identity # before re-signing an outbound token (FR-5 verify+re-sign). "incoming_bearer_token": kwargs.get("incoming_bearer_token"), + "incoming_subject_token": kwargs.get("incoming_subject_token"), "metadata": synthetic_metadata, } user_api_key_auth: Final = kwargs.get("user_api_key_auth") diff --git a/tests/integration/mcp/test_mcp_agent_365_guardrail.py b/tests/integration/mcp/test_mcp_agent_365_guardrail.py index 5842d3ce4a7..e636cd45c44 100644 --- a/tests/integration/mcp/test_mcp_agent_365_guardrail.py +++ b/tests/integration/mcp/test_mcp_agent_365_guardrail.py @@ -136,13 +136,17 @@ def test_a_missing_or_malformed_caller_bearer_blocks_on_every_entry_point_whatev assert f"{rig.alias}-add" in rig.caller().list_tools().tools, "the catalog needs only the virtual key" for entry in ENTRY_POINTS: missing: Final = rig.caller(entry).call(f"{rig.alias}-add", {"entry": entry}, server_id=rig.server_id) - assert missing.error is not None and REJECTED in missing.raw, f"{entry} without a bearer: {missing.raw}" malformed: Final = rig.caller(entry, "not-a-jws").call( f"{rig.alias}-add", {"entry": entry}, server_id=rig.server_id ) - assert malformed.error is not None and REJECTED in malformed.raw, f"{entry} opaque bearer: {malformed.raw}" + for label, outcome in (("without a bearer", missing), ("opaque bearer", malformed)): + assert outcome.error is not None, f"{entry} {label}: {outcome.raw}" + if entry == "server_mcp": + assert outcome.status == 401, f"{entry} {label} skips the connect sign-in challenge: {outcome.raw}" + else: + assert REJECTED in outcome.raw, f"{entry} {label}: {outcome.raw}" assert rig.upstream_tool_names() == () - expected: Final = 2 * len(ENTRY_POINTS) + expected: Final = 2 * (len(ENTRY_POINTS) - 1) assert rig.guardrail_statuses("call_mcp_tool", expected) == ["guardrail_intervened"] * expected diff --git a/tests/integration/mcp/test_mcp_caller_sign_in.py b/tests/integration/mcp/test_mcp_caller_sign_in.py index 1d41be0d78c..9e940470031 100644 --- a/tests/integration/mcp/test_mcp_caller_sign_in.py +++ b/tests/integration/mcp/test_mcp_caller_sign_in.py @@ -171,91 +171,75 @@ def test_jwt_signer_verifies_the_bearer_that_admitted_the_call(gateway: Gateway, assert introspect_stub.drain() == () +AGENT_365_PARAMS: Final = { + "guardrail": "agent_365", + "mode": "pre_mcp_call", + "default_on": True, + "tenant_id": "00000000-0000-0000-0000-000000000000", + "client_id": "22222222-2222-2222-2222-222222222222", + "client_secret": "secret", +} +ENTRA_ISSUER: Final = "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0" +GATEWAY_SCOPE: Final = "api://22222222-2222-2222-2222-222222222222/access_as_user" + + def test_agent_365_gated_server_challenges_at_connect_and_advertises_entra(gateway: Gateway, tmp_path: Path) -> None: - def nothing(request: Request) -> Reply: - return Reply(status=500) + config: Final = _sign_in_config(dict(AGENT_365_PARAMS), tmp_path / "agent365.yaml") + with ( + owned_proxy(gateway, tmp_path, {}, config=config) as candidate, + mcp_peer() as peer, + candidate.scenario() as scenario, + ): + alias: Final = "a365" + uuid.uuid4().hex[:8] + identity: Final = register_mcp(scenario, peer, alias) + granted: Final = scenario.key(object_permission={"mcp_servers": [identity]}) + denied: Final = scenario.key(object_permission={"mcp_servers": ["no-mcp-servers"]}) - with wire_server(nothing) as api: - config: Final = _sign_in_config( - { - "guardrail": "agent_365", - "mode": "pre_mcp_call", - "default_on": True, - "tenant_id": "00000000-0000-0000-0000-000000000000", - "client_id": "22222222-2222-2222-2222-222222222222", - "client_secret": "secret", - "api_base": api.url, - }, - tmp_path / "agent365.yaml", + challenged: Final = _rpc(candidate, f"/mcp/{alias}", granted, {}) + assert challenged.status_code == 401, challenged.text + authenticate: Final = challenged.headers.get("www-authenticate", "") + assert f'resource_metadata="/.well-known/oauth-protected-resource/mcp/{alias}"' in authenticate + assert 'error="invalid_token"' in authenticate + + opaque: Final = _rpc(candidate, f"/mcp/{alias}", granted, {"Authorization": "Bearer not-a-jws"}) + assert opaque.status_code == 401, opaque.text + assert f'resource_metadata="/.well-known/oauth-protected-resource/mcp/{alias}"' in opaque.headers.get( + "www-authenticate", "" ) - with ( - owned_proxy(gateway, tmp_path, {}, config=config) as candidate, - mcp_peer() as peer, - candidate.scenario() as scenario, - ): - alias: Final = "a365" + uuid.uuid4().hex[:8] - identity: Final = register_mcp(scenario, peer, alias) - granted: Final = scenario.key(object_permission={"mcp_servers": [identity]}) - denied: Final = scenario.key(object_permission={"mcp_servers": ["no-mcp-servers"]}) - challenged: Final = _rpc(candidate, f"/mcp/{alias}", granted, {}) - assert challenged.status_code == 401, challenged.text - authenticate: Final = challenged.headers.get("www-authenticate", "") - assert f'resource_metadata="/.well-known/oauth-protected-resource/mcp/{alias}"' in authenticate - assert 'error="invalid_token"' in authenticate + discovery: Final = candidate.client.get(f"/.well-known/oauth-protected-resource/mcp/{alias}") + assert discovery.status_code == 200, discovery.text + document: Final = discovery.json() + assert document["authorization_servers"] == [ENTRA_ISSUER] + assert document["scopes_supported"] == [GATEWAY_SCOPE] - discovery: Final = candidate.client.get(f"/.well-known/oauth-protected-resource/mcp/{alias}") - assert discovery.status_code == 200, discovery.text - document: Final = discovery.json() - assert document["authorization_servers"] == [ - "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0" - ] - assert document["scopes_supported"] == ["api://22222222-2222-2222-2222-222222222222/access_as_user"] - - refused: Final = _rpc(candidate, f"/mcp/{alias}", denied, {}) - assert refused.status_code == 403, refused.text - assert "www-authenticate" not in refused.headers - assert api.drain() == () + refused: Final = _rpc(candidate, f"/mcp/{alias}", denied, {}) + assert refused.status_code == 403, refused.text + assert "www-authenticate" not in refused.headers + assert tool_calls(peer.drain()) == () def test_challenge_and_prm_resolve_the_connected_case_variant(gateway: Gateway, tmp_path: Path) -> None: - def nothing(request: Request) -> Reply: - return Reply(status=500) + config: Final = _sign_in_config(dict(AGENT_365_PARAMS), tmp_path / "agent365-case.yaml") + with ( + owned_proxy(gateway, tmp_path, {}, config=config) as candidate, + mcp_peer() as peer, + candidate.scenario() as scenario, + ): + alias: Final = "a365" + uuid.uuid4().hex[:8] + identity: Final = register_mcp(scenario, peer, alias) + granted: Final = scenario.key(object_permission={"mcp_servers": [identity]}) + connected_as: Final = alias.upper() - with wire_server(nothing) as api: - config: Final = _sign_in_config( - { - "guardrail": "agent_365", - "mode": "pre_mcp_call", - "default_on": True, - "tenant_id": "00000000-0000-0000-0000-000000000000", - "client_id": "22222222-2222-2222-2222-222222222222", - "client_secret": "secret", - "api_base": api.url, - }, - tmp_path / "agent365-case.yaml", - ) - with ( - owned_proxy(gateway, tmp_path, {}, config=config) as candidate, - mcp_peer() as peer, - candidate.scenario() as scenario, - ): - alias: Final = "a365" + uuid.uuid4().hex[:8] - identity: Final = register_mcp(scenario, peer, alias) - granted: Final = scenario.key(object_permission={"mcp_servers": [identity]}) - connected_as: Final = alias.upper() + challenged: Final = _rpc(candidate, f"/mcp/{connected_as}", granted, {}) + assert challenged.status_code == 401, challenged.text + authenticate: Final = challenged.headers.get("www-authenticate", "") + assert f'resource_metadata="/.well-known/oauth-protected-resource/mcp/{connected_as}"' in authenticate + assert 'error="invalid_token"' in authenticate - challenged: Final = _rpc(candidate, f"/mcp/{connected_as}", granted, {}) - assert challenged.status_code == 401, challenged.text - authenticate: Final = challenged.headers.get("www-authenticate", "") - assert f'resource_metadata="/.well-known/oauth-protected-resource/mcp/{connected_as}"' in authenticate - assert 'error="invalid_token"' in authenticate - - discovery: Final = candidate.client.get(f"/.well-known/oauth-protected-resource/mcp/{connected_as}") - assert discovery.status_code == 200, discovery.text - document: Final = discovery.json() - assert document["authorization_servers"] == [ - "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0" - ] - assert document["scopes_supported"] == ["api://22222222-2222-2222-2222-222222222222/access_as_user"] - assert api.drain() == () + discovery: Final = candidate.client.get(f"/.well-known/oauth-protected-resource/mcp/{connected_as}") + assert discovery.status_code == 200, discovery.text + document: Final = discovery.json() + assert document["authorization_servers"] == [ENTRA_ISSUER] + assert document["scopes_supported"] == [GATEWAY_SCOPE] + assert tool_calls(peer.drain()) == () diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index cb24b3a32b7..361d78059ad 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -3787,9 +3787,9 @@ async def test_protected_resource_metadata_resolves_the_connected_case_variant() use_standard_pattern=True, ) - assert result["authorization_servers"] == [ - "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0" - ] + assert result["authorization_servers"] == ( + "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0", + ) assert result["resource"] == "https://llm.example.com/mcp/CATALOG" @@ -7367,21 +7367,21 @@ def test_caller_sign_in_protected_resource_response_names_jwt_issuers(): with patch(_PATCH_ISSUERS, return_value=["https://idp.example.com"]): response = _caller_sign_in_protected_resource_response(_obo_server(scopes=["read"]), _OBO_RESOURCE) assert response == { - "authorization_servers": ["https://idp.example.com"], + "authorization_servers": ("https://idp.example.com",), "resource": _OBO_RESOURCE, - "scopes_supported": ["read"], + "scopes_supported": ("read",), } def test_caller_sign_in_protected_resource_response_scopes_default_empty(): - """A scopeless OBO server reports scopes_supported as [] rather than None.""" + """A scopeless OBO server reports scopes_supported as an empty array rather than None.""" from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( _caller_sign_in_protected_resource_response, ) with patch(_PATCH_ISSUERS, return_value=["https://idp.example.com"]): response = _caller_sign_in_protected_resource_response(_obo_server(scopes=None), _OBO_RESOURCE) - assert response["scopes_supported"] == [] + assert response["scopes_supported"] == () def test_caller_sign_in_protected_resource_response_falls_back_when_no_issuer(): @@ -7440,7 +7440,7 @@ async def test_build_oauth_protected_resource_response_obo_end_to_end(): mcp_server_name="obo_mcp", use_standard_pattern=True, ) - assert response["authorization_servers"] == ["https://idp.example.com"] + assert response["authorization_servers"] == ("https://idp.example.com",) assert response["resource"] == "https://litellm.example.com/mcp/obo_mcp" finally: global_mcp_server_manager.registry.clear() diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_agent_365.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_agent_365.py index 3d4fab45322..a43ffd34145 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_agent_365.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_agent_365.py @@ -1135,6 +1135,34 @@ class _ArgumentMasker(CustomGuardrail): return data +class TestMcpBridgeHandsOverTheSubjectToken: + """The MCP manager separates the raw ``Authorization`` bearer from the caller's subject token (the + bearer minus LiteLLM's own admission credentials). The bridge that turns the manager's kwargs into + the guardrail's data dict has to carry the subject token, or every tool call looks anonymous.""" + + @pytest.mark.asyncio + async def test_manager_kwargs_reach_the_obo_exchange(self): + exchanger: Final = StubTokenExchanger([_ok_exchange()]) + handler: Final = FakeHandler([_allow_response()]) + guardrail: Final = _make_guardrail(handler, exchanger=exchanger) + proxy_logging: Final = ProxyLogging(user_api_key_cache=DualCache()) + manager_kwargs: Final = { + "name": "send_email", + "arguments": {"to": "user@example.com"}, + "server_name": "outlook_mcp", + "user_api_key_auth": _user(), + "incoming_bearer_token": "sk-1234", + "incoming_subject_token": FAKE_ASSERTION, + "headers": {"mcp-session-id": "sess-123"}, + } + data: Final = proxy_logging._convert_mcp_to_llm_format( + proxy_logging._create_mcp_request_object_from_kwargs(manager_kwargs), manager_kwargs + ) + await _run(guardrail, data) + assert [call[0] for call in exchanger.calls] == [FAKE_ASSERTION] + assert handler.calls[0].json["tool"]["name"] == "send_email" + + class TestFinalArgumentsEvaluated: """Agent 365 must judge the arguments that reach the upstream tool. A sibling guardrail that rewrites them must not be able to slip a different argument state past the verdict, whichever way the two @@ -1185,20 +1213,17 @@ class TestCallerSignIn: assert guardrail.caller_sign_in(_server(auth_type=MCPAuth.oauth2), None) is None assert guardrail.caller_sign_in(_server(extra_headers=["authorization"]), None) is None - def test_opted_out_key_does_not_gate(self): - class _OptedOut(Agent365Guardrail): - def should_run_guardrail(self, data, event_type) -> bool: - return False - - guardrail: Final = _OptedOut( - guardrail_name="a365-off", - tenant_id="tenant-abc", - client_id="client-xyz", - client_secret="secret-123", - token_exchanger=StubTokenExchanger(), - default_on=True, + def test_opted_out_key_or_team_does_not_gate(self): + guardrail: Final = _make_guardrail(FakeHandler([])) + opted_out_key: Final = UserAPIKeyAuth( + api_key="k", user_id="u-1", metadata={"opted_out_global_guardrails": [guardrail.guardrail_name]} ) - assert guardrail.caller_sign_in(_server(), UserAPIKeyAuth(api_key="k", user_id="u-1")) is None + opted_out_team: Final = UserAPIKeyAuth( + api_key="k", user_id="u-1", team_metadata={"opted_out_global_guardrails": [guardrail.guardrail_name]} + ) + assert guardrail.caller_sign_in(_server(), opted_out_key) is None + assert guardrail.caller_sign_in(_server(), opted_out_team) is None + assert guardrail.caller_sign_in(_server(), UserAPIKeyAuth(api_key="k", user_id="u-1")) is not None assert guardrail.caller_sign_in(_server(), None) is not None def test_obo_server_with_provider_advertises_both_issuers_and_scopes(self, monkeypatch): @@ -1269,11 +1294,12 @@ class TestPreflightCallerSignIn: assert verdict.fail_open is True @pytest.mark.asyncio - async def test_non_assertion_subject_signs_in_without_exchanging(self): + async def test_non_assertion_subject_is_rejected_without_exchanging(self): exchanger: Final = StubTokenExchanger() guardrail: Final = _make_guardrail(FakeHandler([]), exchanger=exchanger) verdict: Final = await guardrail.preflight_caller_sign_in(_server(), _user(), "opaque-bearer") - assert verdict == SignedIn() + assert isinstance(verdict, Rejected) + assert verdict.claims is None assert exchanger.calls == [] diff --git a/tests/test_litellm/proxy/utils/proxy_logging/test_mcp_bridging.py b/tests/test_litellm/proxy/utils/proxy_logging/test_mcp_bridging.py index 25be3b5de6b..c7fb0848f76 100644 --- a/tests/test_litellm/proxy/utils/proxy_logging/test_mcp_bridging.py +++ b/tests/test_litellm/proxy/utils/proxy_logging/test_mcp_bridging.py @@ -39,6 +39,7 @@ def test_convert_mcp_to_llm_format_returns_synthetic_data(proxy_logging, make_mc "user_api_key_hash": "hash", "user_api_key_request_route": "/mcp", "incoming_bearer_token": "tok", + "incoming_subject_token": "a.b.c", }, ) snapshot = { @@ -47,6 +48,7 @@ def test_convert_mcp_to_llm_format_returns_synthetic_data(proxy_logging, make_mc "mcp_tool_name": out["mcp_tool_name"], "mcp_arguments": out["mcp_arguments"], "incoming_bearer_token": out["incoming_bearer_token"], + "incoming_subject_token": out["incoming_subject_token"], "message_role": out["messages"][0]["role"], } assert snapshot == { @@ -55,6 +57,7 @@ def test_convert_mcp_to_llm_format_returns_synthetic_data(proxy_logging, make_mc "mcp_tool_name": "search", "mcp_arguments": {"q": "hello"}, "incoming_bearer_token": "tok", + "incoming_subject_token": "a.b.c", "message_role": "user", } @@ -66,12 +69,14 @@ def test_convert_mcp_to_llm_format_defaults_model(proxy_logging, make_mcp_reques "model": out["model"], "mcp_tool_name": out["mcp_tool_name"], "incoming_bearer_token": out["incoming_bearer_token"], + "incoming_subject_token": out["incoming_subject_token"], "user_id": out["user_api_key_user_id"], } assert snapshot == { "model": "mcp-tool-call", "mcp_tool_name": "calculator", "incoming_bearer_token": None, + "incoming_subject_token": None, "user_id": None, }