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>
This commit is contained in:
yucheng 2026-09-30 01:57:39 +00:00
parent 072898ab19
commit 4236a43aa7
10 changed files with 146 additions and 124 deletions

View file

@ -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
)

View file

@ -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

View file

@ -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 (

View file

@ -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:

View file

@ -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")

View file

@ -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

View file

@ -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()) == ()

View file

@ -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()

View file

@ -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 == []

View file

@ -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,
}