fix(mcp): keep the plain OBO challenge naming the configured alias on every route

The connect preflight passes connected_as only for caller sign-in servers, so an oauth2_token_exchange server challenges with the merge-base resource_metadata path (its alias) on /mcp/<server_name>, the aggregate route and the legacy route as well as on /mcp/<alias>. The case-variant integration assertion that expected 403 is corrected to the 200 both base and head return

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng 2026-10-02 17:57:24 +00:00
parent 0a1134d06a
commit 0b262d6594
3 changed files with 15 additions and 10 deletions

View file

@ -1741,6 +1741,9 @@ if MCP_AVAILABLE:
# key without access gets the grant's 403 instead of a sign-in it could not use. The one
# admission lookup above serves the challenge, the sign-in preflight and the exchange.
sign_in = caller_sign_in_for(server, user_api_key_auth) if server is not None else None
challenge_route: str | None = (
None if server is not None and server.auth_type == MCPAuth.oauth2_token_exchange else server_name
)
subject_token = (
operations.global_mcp_server_manager._extract_subject_token( # pyright: ignore[reportPrivateUsage] # the manager owns the subject/admission filter shared with the preflight
oauth2_headers, raw_headers, user_api_key_auth
@ -1756,7 +1759,7 @@ if MCP_AVAILABLE:
get_request_root_path,
)
raise_token_exchange_challenge(server, root_path=get_request_root_path(), connected_as=server_name)
raise_token_exchange_challenge(server, root_path=get_request_root_path(), connected_as=challenge_route)
if server and sign_in is not None and subject_token is not None and granted_single:
from litellm.proxy._experimental.mcp_server.caller_sign_in import ( # noqa: PLC0415 # lazy: provider discovery pulls the guardrail registry
preflight_caller_sign_in,
@ -1770,7 +1773,7 @@ if MCP_AVAILABLE:
user_api_key_auth,
subject_token,
root_path=get_request_root_path(),
connected_as=server_name,
connected_as=challenge_route,
)
# Exchange-backed modes (token_exchange's OBO mint, id_jag's stored-assertion mint): run
@ -1786,7 +1789,7 @@ if MCP_AVAILABLE:
oauth2_headers=oauth2_headers,
user_api_key_auth=user_api_key_auth,
raw_headers=raw_headers,
connected_as=server_name,
connected_as=challenge_route,
)
# Pass-through OAuth: when the admin has opted a server into

View file

@ -211,7 +211,7 @@ def test_name_of_a_server_hidden_from_an_external_ip_does_not_reroute_to_a_case_
candidate.client.get(f"/.well-known/oauth-protected-resource/mcp/{stem}", headers=external).status_code
== 404
)
assert _rpc(candidate, f"/mcp/{stem}", key, {}).status_code == 403
assert _rpc(candidate, f"/mcp/{stem}", key, {}).status_code == 200
own_name: Final = _rpc(candidate, f"/mcp/{cased}", key, external)
assert own_name.status_code == 200, own_name.text

View file

@ -10664,7 +10664,7 @@ class TestOboPreflightScopedToAllowedServers:
"x-litellm-api-key": key.api_key,
"authorization": self.SUBJECT_HEADERS["Authorization"],
},
connected_as=requested.alias,
connected_as=None,
)
@ -11574,13 +11574,15 @@ class TestConnectChallengeResolver:
assert exc.value.status_code == 401
@pytest.mark.asyncio
async def test_obo_challenge_www_authenticate_matches_main_byte_for_byte(self, monkeypatch):
@pytest.mark.parametrize("route_name", ["obo", "obo_server"], ids=["alias_route", "server_name_route"])
async def test_obo_challenge_www_authenticate_matches_main_byte_for_byte(self, monkeypatch, route_name):
"""The provider redesign must not change what an OBO server challenges with: the relative
RFC 9728 resource_metadata path plus the RFC 6750 invalid_token triple, exactly as main."""
RFC 9728 resource_metadata path naming the configured alias whichever route the client used,
plus the RFC 6750 invalid_token triple, exactly as main."""
from litellm.proxy._experimental.mcp_server import server as server_module
monkeypatch.delenv("SERVER_ROOT_PATH", raising=False)
obo = _make_obo_server("obo")
obo = _make_obo_server("obo").model_copy(update={"name": "obo_server", "server_name": "obo_server"})
with (
patch.object(
mcp_operations.global_mcp_server_manager,
@ -11590,8 +11592,8 @@ class TestConnectChallengeResolver:
pytest.raises(HTTPException) as exc,
):
await server_module._raise_preemptive_401_for_unauthenticated_servers(
scope={"type": "http", "method": "POST", "path": "/mcp/obo", "headers": []},
mcp_servers=["obo"],
scope={"type": "http", "method": "POST", "path": f"/mcp/{route_name}", "headers": []},
mcp_servers=[route_name],
oauth2_headers=None,
mcp_server_auth_headers=None,
user_api_key_auth=UserAPIKeyAuth(api_key="sk-litellm-virtual-key", user_id="u-1"),