mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
fix(mcp): advertise the gateway resource in oauth_delegate discovery
A non-bridge oauth_delegate server served its protected-resource document with the upstream's resource identifier, which RFC 9728 clients compare against the MCP URL they dialed and reject before any sign-in starts, so the upstream login page never opened. Keep the upstream authorization server, and advertise the gateway URL the client connected to as resource. true_passthrough stays verbatim, and a strict IdP that will not mint a token for the gateway resource has dcr_bridge for that. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
50ae806784
commit
7b3197fdbc
2 changed files with 25 additions and 20 deletions
|
|
@ -2088,13 +2088,15 @@ async def _build_oauth_protected_resource_response(
|
|||
|
||||
For pass-through MCP servers, the gateway proxies the upstream's own
|
||||
``oauth-protected-resource`` metadata so standards-compliant MCP clients
|
||||
discover the **upstream** IdP instead of the gateway. For ``true_passthrough``
|
||||
and ``oauth_delegate`` the metadata is returned verbatim (``resource`` stays
|
||||
the upstream): the caller's token is forwarded to and validated by the
|
||||
upstream, so its audience must be the upstream — rewriting it to the gateway
|
||||
would make a strict IdP (e.g. Entra) refuse to mint it or the upstream reject
|
||||
it. Only the legacy ``is_oauth_passthrough`` opt-in rewrites ``resource`` to
|
||||
the gateway's own URL so clients present the bearer token back to the gateway.
|
||||
discover the **upstream** IdP instead of the gateway. ``resource`` is
|
||||
rewritten to the gateway URL the client dialed, because a document served
|
||||
from the gateway's own well-known path that names a different resource is
|
||||
rejected outright by RFC 9728 clients (they compare it to the MCP URL they
|
||||
connected to) before any sign-in can start. Only ``true_passthrough``, whose
|
||||
contract is that the gateway is invisible and the client transacts with the
|
||||
upstream directly, keeps the upstream metadata verbatim. A strict IdP that
|
||||
refuses to mint a token for the gateway resource needs ``dcr_bridge``, where
|
||||
the gateway holds the upstream token instead of the client.
|
||||
|
||||
An explicitly named gateway-managed oauth2 server (interactive with
|
||||
gateway-vaulted per-user tokens, or M2M) advertises the gateway's own
|
||||
|
|
@ -2171,7 +2173,7 @@ async def _build_oauth_protected_resource_response(
|
|||
)
|
||||
|
||||
if upstream_metadata is not None:
|
||||
if mcp_server.is_true_passthrough or mcp_server.is_oauth_delegate:
|
||||
if mcp_server.is_true_passthrough:
|
||||
return upstream_metadata
|
||||
return {**upstream_metadata, "resource": resource_url}
|
||||
|
||||
|
|
|
|||
|
|
@ -500,13 +500,12 @@ def _make_upstream_metadata_client() -> tuple[dict, MagicMock]:
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_protected_resource_oauth_delegate_returns_upstream_metadata_verbatim():
|
||||
"""oauth_delegate discovery must return the upstream metadata verbatim,
|
||||
resource included. The caller's token is forwarded to and validated by the
|
||||
upstream, so its audience must be the upstream; rewriting resource to the
|
||||
gateway would make a strict IdP refuse to mint it or the upstream reject it.
|
||||
A regression that dropped oauth_delegate from the pass-through predicate would
|
||||
fall through to the gateway-AS branch and advertise LiteLLM as the AS."""
|
||||
@pytest.mark.parametrize("use_standard_pattern", [True, False])
|
||||
async def test_oauth_protected_resource_oauth_delegate_resource_is_the_gateway_url(use_standard_pattern):
|
||||
"""A non-bridge oauth_delegate server keeps the upstream authorization server but must
|
||||
advertise the gateway URL the client dialed as ``resource``: an RFC 9728 client compares
|
||||
``resource`` against the MCP URL it connected to and refuses to start the upstream sign-in
|
||||
when they differ (#36803). Returning the upstream document verbatim regresses that."""
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
||||
global_mcp_server_manager,
|
||||
)
|
||||
|
|
@ -520,6 +519,7 @@ async def test_oauth_protected_resource_oauth_delegate_returns_upstream_metadata
|
|||
url="https://upstream.example.com/mcp",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth_delegate,
|
||||
dcr_bridge=False,
|
||||
)
|
||||
global_mcp_server_manager.registry[delegate_server.server_id] = delegate_server
|
||||
|
||||
|
|
@ -529,15 +529,18 @@ async def test_oauth_protected_resource_oauth_delegate_returns_upstream_metadata
|
|||
result = await _build_oauth_protected_resource_response(
|
||||
request=_make_request(),
|
||||
mcp_server_name="sample_docs",
|
||||
use_standard_pattern=True,
|
||||
use_standard_pattern=use_standard_pattern,
|
||||
)
|
||||
|
||||
assert result == upstream_payload
|
||||
assert result["authorization_servers"] == ["https://okta.example.com/oauth2/default"]
|
||||
assert result["resource"] == "https://upstream.example.com/mcp"
|
||||
finally:
|
||||
global_mcp_server_manager.registry.clear()
|
||||
|
||||
expected_resource = (
|
||||
"https://gateway.example.com/mcp/sample_docs"
|
||||
if use_standard_pattern
|
||||
else "https://gateway.example.com/sample_docs/mcp"
|
||||
)
|
||||
assert result == {**upstream_payload, "resource": expected_resource}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_protected_resource_true_passthrough_returns_upstream_metadata_verbatim():
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue