mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Merge pull request #38379 from BerriAI/litellm_mcp_oauth_admin_entered_authorize_urls
fix(mcp): honor admin-entered OAuth URLs on authorize after issuer yield
This commit is contained in:
commit
39dd46397e
12 changed files with 652 additions and 119 deletions
|
|
@ -1600,7 +1600,7 @@ async def refresh_user_oauth_token(
|
|||
) -> OAuthCredentialPayload | None:
|
||||
"""Attempt to refresh a per-user OAuth2 token using its stored refresh_token.
|
||||
|
||||
POSTs to ``server.token_url`` with ``grant_type=refresh_token``.
|
||||
POSTs to ``server.effective_token_url`` with ``grant_type=refresh_token``.
|
||||
|
||||
On success: persists the new credential via ``store_user_oauth_credential``
|
||||
and returns the updated payload dict.
|
||||
|
|
@ -1609,7 +1609,7 @@ async def refresh_user_oauth_token(
|
|||
stale credential and triggering re-authentication.
|
||||
"""
|
||||
refresh_token: Final[str | None] = cred.get("refresh_token")
|
||||
token_url: Final[str | None] = getattr(server, "token_url", None)
|
||||
token_url: Final[str | None] = getattr(server, "effective_token_url", None) or getattr(server, "token_url", None)
|
||||
server_id: Final[str] = getattr(server, "server_id", "")
|
||||
client_id: Final[str | None] = getattr(server, "client_id", None)
|
||||
client_secret: Final[str | None] = getattr(server, "client_secret", None)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import html as _html
|
|||
import json
|
||||
import secrets
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Callable, Mapping
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, Optional
|
||||
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
||||
|
|
@ -663,6 +663,26 @@ def _endpoint_not_configured_detail(
|
|||
)
|
||||
|
||||
|
||||
async def _server_with_oauth_endpoints(
|
||||
mcp_server: MCPServer,
|
||||
needed_endpoint: Callable[[MCPServer], str | None],
|
||||
) -> MCPServer:
|
||||
"""Join deferred OAuth discovery only when the endpoint this caller needs is still missing.
|
||||
|
||||
Admin-entered endpoints live on ``configured_*`` after an anchored issuer empties the
|
||||
resolved fields. A caller whose needed endpoint already resolves never awaits discovery
|
||||
and cannot 503 over a leftover pin. A server still missing it joins the deferred task;
|
||||
no slot is a no-op and the caller 400s.
|
||||
"""
|
||||
if needed_endpoint(mcp_server) is not None:
|
||||
return mcp_server
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 # circular import with mcp_server_manager at module load
|
||||
global_mcp_server_manager,
|
||||
)
|
||||
|
||||
return await global_mcp_server_manager.ensure_oauth_metadata_discovered(mcp_server)
|
||||
|
||||
|
||||
def _raise_unless_oauth2_discovery_server(
|
||||
mcp_server: MCPServer | None,
|
||||
mcp_server_name: str | None,
|
||||
|
|
@ -697,7 +717,7 @@ def _dcr_bridge_relays_client_registration(mcp_server: MCPServer) -> bool:
|
|||
returns directly to the client's redirect URI without transiting the gateway. Gateway-side
|
||||
redirect trust and the ``/callback`` state relay therefore only apply to the short-circuit
|
||||
arm, where the upstream only knows the gateway's own callback."""
|
||||
return mcp_server.is_dcr_bridge and bool(mcp_server.registration_url) and not mcp_server.client_id
|
||||
return mcp_server.is_dcr_bridge and bool(mcp_server.effective_registration_url) and not mcp_server.client_id
|
||||
|
||||
|
||||
def _require_s256_pkce(
|
||||
|
|
@ -745,7 +765,7 @@ def _redirect_to_upstream_authorize(
|
|||
**({"scope": scope_value} if scope_value else {}),
|
||||
**({"resource": upstream_resource} if upstream_resource else {}),
|
||||
}
|
||||
parsed_auth_url: Final = urlparse(mcp_server.authorization_url or "")
|
||||
parsed_auth_url: Final = urlparse(mcp_server.effective_authorization_url or "")
|
||||
merged_params: Final = {**dict(parse_qsl(parsed_auth_url.query)), **passthrough_params}
|
||||
return RedirectResponse(urlunparse(parsed_auth_url._replace(query=urlencode(merged_params))))
|
||||
|
||||
|
|
@ -812,18 +832,19 @@ async def authorize_with_server(
|
|||
ephemeral_dcr_client: "EphemeralDcrClient | None" = None,
|
||||
):
|
||||
_raise_if_not_oauth2(mcp_server)
|
||||
if mcp_server.authorization_url is None:
|
||||
resolved_server: Final = await _server_with_oauth_endpoints(mcp_server, _register_flow_needed_endpoint)
|
||||
if resolved_server.effective_authorization_url is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=_endpoint_not_configured_detail(
|
||||
mcp_server,
|
||||
resolved_server,
|
||||
"authorization url",
|
||||
"set Authorization URL and Token URL manually",
|
||||
"set Issuer to discover them from the identity provider (RFC 8414)",
|
||||
),
|
||||
)
|
||||
|
||||
if mcp_server.is_dcr_bridge:
|
||||
if resolved_server.is_dcr_bridge:
|
||||
# Enforce S256 PKCE on both bridge arms. The relay arm forwards the validated,
|
||||
# now-non-optional pair to the upstream authorize; the short-circuit arm keeps
|
||||
# calling this for its enforcement side effect, then falls through to the gateway
|
||||
|
|
@ -832,9 +853,9 @@ async def authorize_with_server(
|
|||
# A gateway-minted ephemeral client is registered against {base}/callback, so its
|
||||
# flow must run the short-circuit arm; the relay arm is only for clients that
|
||||
# registered themselves through the front door and hold their own redirect binding.
|
||||
if _dcr_bridge_relays_client_registration(mcp_server) and ephemeral_dcr_client is None:
|
||||
if _dcr_bridge_relays_client_registration(resolved_server) and ephemeral_dcr_client is None:
|
||||
return _redirect_to_upstream_authorize(
|
||||
mcp_server=mcp_server,
|
||||
mcp_server=resolved_server,
|
||||
client_id=client_id,
|
||||
redirect_uri=redirect_uri,
|
||||
state=state,
|
||||
|
|
@ -860,7 +881,7 @@ async def authorize_with_server(
|
|||
# litellm key, so the browser session is the only identity source; without one there is nothing to
|
||||
# bind, so send the user through login first. Every other oauth2 server keeps the identity-less state.
|
||||
litellm_user_id: str | None = None
|
||||
if mcp_server.is_dcr_bridge and mcp_server.is_oauth_delegate:
|
||||
if resolved_server.is_dcr_bridge and resolved_server.is_oauth_delegate:
|
||||
from litellm.proxy._experimental.mcp_server.byok_oauth_endpoints import ( # noqa: PLC0415 # inline import avoids a module-load circular import
|
||||
_user_id_from_session_cookie,
|
||||
)
|
||||
|
|
@ -870,7 +891,7 @@ async def authorize_with_server(
|
|||
return _redirect_to_litellm_login(request)
|
||||
denial: Final = await _bridge_authorize_access_denial(
|
||||
litellm_user_id=litellm_user_id,
|
||||
mcp_server=mcp_server,
|
||||
mcp_server=resolved_server,
|
||||
redirect_uri=redirect_uri,
|
||||
state=state,
|
||||
)
|
||||
|
|
@ -884,7 +905,7 @@ async def authorize_with_server(
|
|||
code_challenge_method=code_challenge_method,
|
||||
client_redirect_uri=redirect_uri,
|
||||
litellm_user_id=litellm_user_id,
|
||||
mcp_server_id=mcp_server.server_id if (litellm_user_id or ephemeral_dcr_client) else None,
|
||||
mcp_server_id=resolved_server.server_id if (litellm_user_id or ephemeral_dcr_client) else None,
|
||||
dcr_client_id=ephemeral_dcr_client.client_id if ephemeral_dcr_client else None,
|
||||
dcr_client_secret=ephemeral_dcr_client.client_secret if ephemeral_dcr_client else None,
|
||||
dcr_token_endpoint_auth_method=ephemeral_dcr_client.token_endpoint_auth_method
|
||||
|
|
@ -894,26 +915,26 @@ async def authorize_with_server(
|
|||
relay_state: Final = secrets.token_urlsafe(_OAUTH_STATE_HANDLE_BYTES)
|
||||
|
||||
params: Final = {
|
||||
"client_id": mcp_server.client_id if mcp_server.client_id else client_id,
|
||||
"client_id": resolved_server.client_id if resolved_server.client_id else client_id,
|
||||
"redirect_uri": f"{request_base_url}/callback",
|
||||
"state": relay_state,
|
||||
"response_type": response_type or "code",
|
||||
}
|
||||
if scope:
|
||||
params["scope"] = scope
|
||||
elif mcp_server.scopes:
|
||||
params["scope"] = " ".join(mcp_server.scopes)
|
||||
elif resolved_server.scopes:
|
||||
params["scope"] = " ".join(resolved_server.scopes)
|
||||
|
||||
if code_challenge:
|
||||
params["code_challenge"] = code_challenge
|
||||
if code_challenge_method:
|
||||
params["code_challenge_method"] = code_challenge_method
|
||||
|
||||
upstream_resource: Final = resolve_upstream_resource(mcp_server)
|
||||
upstream_resource: Final = resolve_upstream_resource(resolved_server)
|
||||
if upstream_resource:
|
||||
params["resource"] = upstream_resource
|
||||
|
||||
parsed_auth_url: Final = urlparse(mcp_server.authorization_url)
|
||||
parsed_auth_url: Final = urlparse(resolved_server.effective_authorization_url)
|
||||
existing_params: Final = dict(parse_qsl(parsed_auth_url.query))
|
||||
existing_params.update(params)
|
||||
final_url: Final = urlunparse(parsed_auth_url._replace(query=urlencode(existing_params)))
|
||||
|
|
@ -946,11 +967,13 @@ async def exchange_token_with_server(
|
|||
if grant_type not in ("authorization_code", "refresh_token"):
|
||||
raise HTTPException(status_code=400, detail="Unsupported grant_type")
|
||||
|
||||
if mcp_server.token_url is None:
|
||||
resolved_server: Final = await _server_with_oauth_endpoints(mcp_server, _token_flow_needed_endpoint)
|
||||
token_url: Final = resolved_server.effective_token_url
|
||||
if token_url is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=_endpoint_not_configured_detail(
|
||||
mcp_server,
|
||||
resolved_server,
|
||||
"token url",
|
||||
"set Token URL manually",
|
||||
"set Issuer to discover it from the identity provider (RFC 8414)",
|
||||
|
|
@ -965,16 +988,16 @@ async def exchange_token_with_server(
|
|||
# recovered from a sealed code) must authenticate the way its own registration was granted,
|
||||
# not the way the server row is configured; callers that carry no method keep the row's method
|
||||
# as before.
|
||||
resolved_client_id: Final = mcp_server.client_id if mcp_server.client_id else client_id
|
||||
resolved_client_secret: Final = mcp_server.client_secret if mcp_server.client_id else client_secret
|
||||
resolved_client_id: Final = resolved_server.client_id if resolved_server.client_id else client_id
|
||||
resolved_client_secret: Final = resolved_server.client_secret if resolved_server.client_id else client_secret
|
||||
resolved_auth_method: Final = (
|
||||
mcp_server.token_endpoint_auth_method
|
||||
if mcp_server.client_id
|
||||
else (client_token_endpoint_auth_method or mcp_server.token_endpoint_auth_method)
|
||||
resolved_server.token_endpoint_auth_method
|
||||
if resolved_server.client_id
|
||||
else (client_token_endpoint_auth_method or resolved_server.token_endpoint_auth_method)
|
||||
)
|
||||
try:
|
||||
token_request: Final = build_upstream_oauth2_token_request(
|
||||
mcp_server,
|
||||
resolved_server,
|
||||
auth_method=resolved_auth_method,
|
||||
client_id=resolved_client_id,
|
||||
client_secret=resolved_client_secret,
|
||||
|
|
@ -987,14 +1010,14 @@ async def exchange_token_with_server(
|
|||
bridge_upstream_refresh: SecretStr | None = None
|
||||
bridge_upstream_scope: str | None = None
|
||||
refresh_request_scope: str | None = None
|
||||
is_bridge: Final = mcp_server.is_oauth_delegate and mcp_server.is_dcr_bridge
|
||||
is_bridge: Final = resolved_server.is_oauth_delegate and resolved_server.is_dcr_bridge
|
||||
|
||||
if grant_type == "refresh_token":
|
||||
# Phase 1 for a bridge refresh: open the client's refresh envelope, re-validate the sealed
|
||||
# identity, and unwrap the real upstream refresh token BEFORE building token_data, so the exchange
|
||||
# sends the upstream token and never the envelope. A failure returns without touching the upstream.
|
||||
if is_bridge:
|
||||
prepared_refresh: Final = await _prepare_bridge_refresh(mcp_server, refresh_token)
|
||||
prepared_refresh: Final = await _prepare_bridge_refresh(resolved_server, refresh_token)
|
||||
if not isinstance(prepared_refresh, _BridgeRefreshReady):
|
||||
return _bridge_mint_error_response(prepared_refresh)
|
||||
bridge_mint_ready = prepared_refresh.ready
|
||||
|
|
@ -1031,13 +1054,13 @@ async def exchange_token_with_server(
|
|||
# A raw upstream code (scripted path) opens to None and the code is used as-is.
|
||||
bridge_identity = open_bridge_authorization_code(code)
|
||||
if bridge_identity is not None:
|
||||
if bridge_identity.mcp_server_id != mcp_server.server_id:
|
||||
if bridge_identity.mcp_server_id != resolved_server.server_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Authorization code was issued for a different MCP server",
|
||||
)
|
||||
code = bridge_identity.upstream_code
|
||||
bridge_token_relay: Final = _dcr_bridge_relays_client_registration(mcp_server)
|
||||
bridge_token_relay: Final = _dcr_bridge_relays_client_registration(resolved_server)
|
||||
if bridge_token_relay and not redirect_uri:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
|
|
@ -1059,7 +1082,7 @@ async def exchange_token_with_server(
|
|||
# Phase 1 for a bridge authorization_code mint: resolve identity (the SSO user recovered above, or
|
||||
# the presented litellm key) and the envelope keys BEFORE the exchange consumes the single-use code.
|
||||
if is_bridge:
|
||||
prepared: Final = await _prepare_bridge_mint(request, mcp_server, bridge_identity)
|
||||
prepared: Final = await _prepare_bridge_mint(request, resolved_server, bridge_identity)
|
||||
if not isinstance(prepared, _BridgeMintReady):
|
||||
return _bridge_mint_error_response(prepared)
|
||||
bridge_mint_ready = prepared
|
||||
|
|
@ -1067,7 +1090,7 @@ async def exchange_token_with_server(
|
|||
async_client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.Oauth2Check)
|
||||
try:
|
||||
response: Final = await async_client.post(
|
||||
mcp_server.token_url,
|
||||
token_url,
|
||||
headers={"Accept": "application/json", **token_request.headers},
|
||||
data=token_data,
|
||||
)
|
||||
|
|
@ -1076,8 +1099,8 @@ async def exchange_token_with_server(
|
|||
except httpx.HTTPStatusError as exc:
|
||||
fault: Final = classify_upstream_token_rejection(
|
||||
exc.response,
|
||||
credential_source=_token_credential_source(mcp_server),
|
||||
log_context=mcp_server.server_id,
|
||||
credential_source=_token_credential_source(resolved_server),
|
||||
log_context=resolved_server.server_id,
|
||||
)
|
||||
upstream_rejected_bridge_refresh: Final = (
|
||||
is_bridge
|
||||
|
|
@ -1090,7 +1113,7 @@ async def exchange_token_with_server(
|
|||
"bridge refresh: the upstream rejected the sealed refresh token for server=%s with "
|
||||
"invalid_grant (revoked or expired at the IdP); returning invalid_grant so the client "
|
||||
"re-runs authorization_code rather than an opaque upstream error",
|
||||
mcp_server.server_id,
|
||||
resolved_server.server_id,
|
||||
)
|
||||
return _bridge_mint_error_response("invalid_refresh")
|
||||
return render_token_fault(fault)
|
||||
|
|
@ -1103,22 +1126,22 @@ async def exchange_token_with_server(
|
|||
|
||||
# Validate token response against server-configured rules before any storage.
|
||||
# This rejects tokens from wrong Slack workspaces, Atlassian orgs, etc.
|
||||
if mcp_server.token_validation and isinstance(mcp_server.token_validation, dict):
|
||||
if resolved_server.token_validation and isinstance(resolved_server.token_validation, dict):
|
||||
_validate_token_response(
|
||||
token_response=token_response,
|
||||
validation_rules=mcp_server.token_validation,
|
||||
server_id=mcp_server.server_id,
|
||||
validation_rules=resolved_server.token_validation,
|
||||
server_id=resolved_server.server_id,
|
||||
)
|
||||
|
||||
# Store server-side when the server is configured for per-user OAuth and
|
||||
# the calling client has provided a valid LiteLLM identity.
|
||||
# Errors are non-fatal: the token is still returned to the client.
|
||||
if mcp_server.needs_user_oauth_token:
|
||||
if resolved_server.needs_user_oauth_token:
|
||||
user_id: Final = await _extract_user_id_from_request(request)
|
||||
if user_id:
|
||||
try:
|
||||
await _store_per_user_token_server_side(
|
||||
server=mcp_server,
|
||||
server=resolved_server,
|
||||
user_id=user_id,
|
||||
token_response=token_response,
|
||||
)
|
||||
|
|
@ -1126,7 +1149,7 @@ async def exchange_token_with_server(
|
|||
verbose_logger.warning(
|
||||
"exchange_token_with_server: server-side storage failed for user=%s server=%s: %s",
|
||||
user_id,
|
||||
mcp_server.server_id,
|
||||
resolved_server.server_id,
|
||||
exc,
|
||||
)
|
||||
else:
|
||||
|
|
@ -1136,7 +1159,7 @@ async def exchange_token_with_server(
|
|||
"requires the stored token, so the client will be challenged with 401 on reconnect. "
|
||||
"Ensure the request carries a valid LiteLLM key (x-litellm-api-key or Authorization), "
|
||||
"or store it via POST /mcp/server/{id}/oauth-user-credential.",
|
||||
mcp_server.server_id,
|
||||
resolved_server.server_id,
|
||||
)
|
||||
|
||||
# A DCR-bridge oauth_delegate server hands the client a gateway-bound envelope (identity plus the
|
||||
|
|
@ -1147,7 +1170,9 @@ async def exchange_token_with_server(
|
|||
token_response = {**token_response, "scope": refresh_request_scope}
|
||||
# Phase 3: seal the upstream grant into the client-held envelope; failures map through the same
|
||||
# OAuth-shaped response as the phase-1 preconditions.
|
||||
minted: Final = _finish_bridge_mint(bridge_mint_ready, mcp_server, token_response, datetime.now(timezone.utc))
|
||||
minted: Final = _finish_bridge_mint(
|
||||
bridge_mint_ready, resolved_server, token_response, datetime.now(timezone.utc)
|
||||
)
|
||||
return minted if isinstance(minted, JSONResponse) else _bridge_mint_error_response(minted)
|
||||
|
||||
raw_access_token: Final = token_response.get("access_token") if isinstance(token_response, dict) else None
|
||||
|
|
@ -1551,7 +1576,8 @@ async def mint_ephemeral_dcr_client(request: Request, mcp_server: MCPServer) ->
|
|||
bounded by the server count even when the request origin varies) so parallel authorize requests
|
||||
cannot each register an upstream client; the cache stamps nothing onto the server record and
|
||||
correctness never depends on it because the sealed state carries the client through the flow."""
|
||||
if mcp_server.registration_url is None:
|
||||
registration_url: Final = mcp_server.effective_registration_url
|
||||
if registration_url is None:
|
||||
return None
|
||||
request_base_url: Final = get_request_base_url(request)
|
||||
cache_key: Final = f"mcp_ephemeral_dcr_client:{mcp_server.server_id}:{request_base_url}"
|
||||
|
|
@ -1571,7 +1597,7 @@ async def mint_ephemeral_dcr_client(request: Request, mcp_server: MCPServer) ->
|
|||
"token_endpoint_auth_method": "none",
|
||||
}
|
||||
response: Final = await _post_dcr_registration(
|
||||
registration_url=mcp_server.registration_url,
|
||||
registration_url=registration_url,
|
||||
register_data=register_data,
|
||||
server_id=mcp_server.server_id,
|
||||
)
|
||||
|
|
@ -1617,7 +1643,7 @@ async def resolve_ephemeral_dcr_client(
|
|||
usable to generate orphan IdP clients)."""
|
||||
if not (mcp_server.is_true_passthrough or (mcp_server.is_oauth_delegate and not mcp_server.is_dcr_bridge)):
|
||||
return None
|
||||
if mcp_server.authorization_url is None:
|
||||
if mcp_server.effective_authorization_url is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="MCP server authorization url is not set",
|
||||
|
|
@ -1627,6 +1653,29 @@ async def resolve_ephemeral_dcr_client(
|
|||
return await mint_ephemeral_dcr_client(request, mcp_server)
|
||||
|
||||
|
||||
def _register_flow_needed_endpoint(mcp_server: MCPServer) -> str | None:
|
||||
"""The register flow's deferred-discovery join gate. A DCR bridge with no admin-configured
|
||||
client can only register callers through the upstream's registration endpoint
|
||||
(``_oauth_endpoints_unresolved`` keeps its discovery slot armed for exactly this shape), so
|
||||
the flow must keep joining discovery while registration is still missing instead of silently
|
||||
degrading to the dummy short-circuit. Every other shape only needs the authorization url."""
|
||||
if mcp_server.is_dcr_bridge and not mcp_server.client_id and mcp_server.effective_registration_url is None:
|
||||
return None
|
||||
return mcp_server.effective_authorization_url
|
||||
|
||||
|
||||
def _token_flow_needed_endpoint(mcp_server: MCPServer) -> str | None:
|
||||
"""The token exchange's deferred-discovery join gate. The exchange's relay-vs-callback arm
|
||||
(:func:`_dcr_bridge_relays_client_registration`) reads the registration url, so a clientless
|
||||
DCR bridge rebuilt without its discovered registration endpoint must keep joining discovery
|
||||
even when the token url already resolves; skipping it would select the gateway-callback arm
|
||||
and the upstream would reject the code over a redirect_uri mismatch. Every other shape only
|
||||
needs the token url."""
|
||||
if mcp_server.is_dcr_bridge and not mcp_server.client_id and mcp_server.effective_registration_url is None:
|
||||
return None
|
||||
return mcp_server.effective_token_url
|
||||
|
||||
|
||||
async def register_client_with_server(
|
||||
request: Request,
|
||||
mcp_server: MCPServer,
|
||||
|
|
@ -1661,21 +1710,23 @@ async def register_client_with_server(
|
|||
):
|
||||
return dummy_return
|
||||
|
||||
if mcp_server.authorization_url is None:
|
||||
resolved_server: Final = await _server_with_oauth_endpoints(mcp_server, _register_flow_needed_endpoint)
|
||||
if resolved_server.effective_authorization_url is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=_endpoint_not_configured_detail(
|
||||
mcp_server,
|
||||
resolved_server,
|
||||
"authorization url",
|
||||
"set Authorization URL and Token URL manually",
|
||||
"set Issuer to discover them from the identity provider (RFC 8414)",
|
||||
),
|
||||
)
|
||||
|
||||
if mcp_server.registration_url is None:
|
||||
registration_url: Final = resolved_server.effective_registration_url
|
||||
if registration_url is None:
|
||||
return dummy_return
|
||||
|
||||
bridge_relay: Final = _dcr_bridge_relays_client_registration(mcp_server)
|
||||
bridge_relay: Final = _dcr_bridge_relays_client_registration(resolved_server)
|
||||
if bridge_relay and not client_redirect_uris:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
|
|
@ -1690,15 +1741,17 @@ async def register_client_with_server(
|
|||
"token_endpoint_auth_method": token_endpoint_auth_method or ("none" if bridge_relay else ""),
|
||||
}
|
||||
response: Final = await _post_dcr_registration(
|
||||
registration_url=mcp_server.registration_url,
|
||||
registration_url=registration_url,
|
||||
register_data=register_data,
|
||||
server_id=mcp_server.server_id,
|
||||
server_id=resolved_server.server_id,
|
||||
)
|
||||
|
||||
token_response = response.json()
|
||||
|
||||
if persist_credentials and not bridge_relay:
|
||||
persistence_result = await _persist_dcr_client_registration(mcp_server, token_response, current_redirect_uri)
|
||||
persistence_result = await _persist_dcr_client_registration(
|
||||
resolved_server, token_response, current_redirect_uri
|
||||
)
|
||||
if persistence_result == "reused":
|
||||
return dummy_return
|
||||
|
||||
|
|
@ -1755,17 +1808,10 @@ async def authorize(
|
|||
lookup_name: Final[str | None] = mcp_server_name or client_id
|
||||
client_ip: Final = IPAddressUtils.get_mcp_client_ip(request)
|
||||
mcp_server = (
|
||||
await global_mcp_server_manager.get_resolved_mcp_server_by_name(lookup_name, client_ip=client_ip)
|
||||
if lookup_name
|
||||
else None
|
||||
global_mcp_server_manager.get_mcp_server_by_name(lookup_name, client_ip=client_ip) if lookup_name else None
|
||||
)
|
||||
if mcp_server is None and mcp_server_name is None:
|
||||
unresolved_server: Final = _resolve_oauth2_server_for_root_endpoints(client_ip=client_ip)
|
||||
mcp_server = (
|
||||
await global_mcp_server_manager.ensure_oauth_metadata_discovered(unresolved_server)
|
||||
if unresolved_server is not None
|
||||
else None
|
||||
)
|
||||
mcp_server = _resolve_oauth2_server_for_root_endpoints(client_ip=client_ip)
|
||||
if mcp_server is None:
|
||||
raise HTTPException(status_code=404, detail="MCP server not found")
|
||||
_raise_if_not_oauth2(mcp_server)
|
||||
|
|
@ -1846,14 +1892,9 @@ async def token_endpoint(
|
|||
|
||||
lookup_name: Final = mcp_server_name or client_id
|
||||
client_ip: Final = IPAddressUtils.get_mcp_client_ip(request)
|
||||
mcp_server = await global_mcp_server_manager.get_resolved_mcp_server_by_name(lookup_name, client_ip=client_ip)
|
||||
mcp_server = global_mcp_server_manager.get_mcp_server_by_name(lookup_name, client_ip=client_ip)
|
||||
if mcp_server is None and mcp_server_name is None:
|
||||
unresolved_server: Final = _resolve_oauth2_server_for_root_endpoints(client_ip=client_ip)
|
||||
mcp_server = (
|
||||
await global_mcp_server_manager.ensure_oauth_metadata_discovered(unresolved_server)
|
||||
if unresolved_server is not None
|
||||
else None
|
||||
)
|
||||
mcp_server = _resolve_oauth2_server_for_root_endpoints(client_ip=client_ip)
|
||||
if mcp_server is None:
|
||||
raise HTTPException(status_code=404, detail="MCP server not found")
|
||||
return await exchange_token_with_server(
|
||||
|
|
@ -2684,10 +2725,9 @@ async def register_client(request: Request, mcp_server_name: str | None = None):
|
|||
return await register_aggregate_client(request=request, request_body=data)
|
||||
resolved: Final = _resolve_oauth2_server_for_root_endpoints(client_ip=client_ip)
|
||||
if resolved:
|
||||
resolved_server: Final = await global_mcp_server_manager.ensure_oauth_metadata_discovered(resolved)
|
||||
return await register_client_with_server(
|
||||
request=request,
|
||||
mcp_server=resolved_server,
|
||||
mcp_server=resolved,
|
||||
client_name=data.get("client_name", ""),
|
||||
grant_types=data.get("grant_types", []),
|
||||
response_types=data.get("response_types", []),
|
||||
|
|
@ -2697,10 +2737,7 @@ async def register_client(request: Request, mcp_server_name: str | None = None):
|
|||
)
|
||||
return dummy_return
|
||||
|
||||
mcp_server: Final = await global_mcp_server_manager.get_resolved_mcp_server_by_name(
|
||||
mcp_server_name,
|
||||
client_ip=client_ip,
|
||||
)
|
||||
mcp_server: Final = global_mcp_server_manager.get_mcp_server_by_name(mcp_server_name, client_ip=client_ip)
|
||||
if mcp_server is None:
|
||||
return dummy_return
|
||||
return await register_client_with_server(
|
||||
|
|
|
|||
|
|
@ -523,7 +523,7 @@ def _oauth_endpoints_unresolved(server: MCPServer) -> bool:
|
|||
# can come from resource discovery, so a server that resolved its endpoints but no scopes is
|
||||
# still unresolved for its flow.
|
||||
return True
|
||||
if server.is_dcr_bridge and not server.client_id and server.registration_url is None:
|
||||
if server.is_dcr_bridge and not server.client_id and server.effective_registration_url is None:
|
||||
# A DCR bridge with no admin-configured client can only register callers through the
|
||||
# upstream's registration endpoint, so a build that resolved the authorize and token
|
||||
# endpoints but not registration_endpoint (partial metadata) is still unresolved for its
|
||||
|
|
@ -535,8 +535,8 @@ def _oauth_endpoints_unresolved(server: MCPServer) -> bool:
|
|||
return _flow_endpoints_missing(
|
||||
server.auth_type,
|
||||
MCPServerManager.effective_oauth2_flow(server),
|
||||
server.authorization_url,
|
||||
server.token_url,
|
||||
server.effective_authorization_url,
|
||||
server.effective_token_url,
|
||||
server.token_exchange_endpoint,
|
||||
)
|
||||
|
||||
|
|
@ -6205,14 +6205,6 @@ class MCPServerManager:
|
|||
return server
|
||||
return None
|
||||
|
||||
async def get_resolved_mcp_server_by_name(
|
||||
self,
|
||||
server_name: str,
|
||||
client_ip: str | None = None,
|
||||
) -> MCPServer | None:
|
||||
server: Final = self.get_mcp_server_by_name(server_name, client_ip=client_ip)
|
||||
return await self.ensure_oauth_metadata_discovered(server) if server is not None else None
|
||||
|
||||
def get_filtered_registry(self, client_ip: str | None = None) -> dict[str, MCPServer]:
|
||||
"""
|
||||
Get registry filtered by client IP access control.
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ class MCPOAuth2TokenCache(InMemoryCache):
|
|||
rest of the identity rather than stored in a key."""
|
||||
material: Final = "\x00".join(
|
||||
(
|
||||
server.token_url or "",
|
||||
server.effective_token_url or "",
|
||||
server.client_id or "",
|
||||
server.client_secret or "",
|
||||
" ".join(server.scopes or ()),
|
||||
|
|
@ -82,7 +82,7 @@ class MCPOAuth2TokenCache(InMemoryCache):
|
|||
|
||||
@staticmethod
|
||||
def _has_client_credentials_config(server: "MCPServer") -> bool:
|
||||
return bool(server.client_id and server.client_secret and server.token_url)
|
||||
return bool(server.client_id and server.client_secret and server.effective_token_url)
|
||||
|
||||
async def async_get_token(self, server: "MCPServer") -> str | None:
|
||||
"""Return a valid access token, fetching or refreshing as needed.
|
||||
|
|
@ -112,19 +112,20 @@ class MCPOAuth2TokenCache(InMemoryCache):
|
|||
return token
|
||||
|
||||
async def _fetch_token(self, server: "MCPServer") -> tuple[str, int]:
|
||||
"""POST to ``token_url`` with ``grant_type=client_credentials``.
|
||||
"""POST to ``effective_token_url`` with ``grant_type=client_credentials``.
|
||||
|
||||
Returns ``(access_token, ttl_seconds)`` where ttl accounts for the
|
||||
expiry buffer so the cache entry expires before the real token does.
|
||||
"""
|
||||
client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP)
|
||||
|
||||
if not server.client_id or not server.client_secret or not server.token_url:
|
||||
token_url: Final = server.effective_token_url
|
||||
if not server.client_id or not server.client_secret or not token_url:
|
||||
raise ValueError(
|
||||
f"MCP server '{server.server_id}' missing required OAuth2 fields: "
|
||||
f"client_id={bool(server.client_id)}, "
|
||||
f"client_secret={bool(server.client_secret)}, "
|
||||
f"token_url={bool(server.token_url)}"
|
||||
f"token_url={bool(token_url)}"
|
||||
)
|
||||
|
||||
token_request: Final = build_upstream_oauth2_token_request(
|
||||
|
|
@ -146,7 +147,7 @@ class MCPOAuth2TokenCache(InMemoryCache):
|
|||
)
|
||||
|
||||
try:
|
||||
response: Final = await client.post(server.token_url, data=data, headers=token_request.headers or None)
|
||||
response: Final = await client.post(token_url, data=data, headers=token_request.headers or None)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise ValueError(
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ def _client_credentials_spec(server: MCPServer, resource: str) -> ServerSpec:
|
|||
config=ClientCredentialsConfig(
|
||||
client_id=server.client_id,
|
||||
client_secret=SecretStr(server.client_secret) if server.client_secret else None,
|
||||
token_url=server.token_url,
|
||||
token_url=server.effective_token_url,
|
||||
scopes=tuple(server.scopes or ()),
|
||||
audience=server.audience,
|
||||
upstream_resource=resolve_upstream_resource(server),
|
||||
|
|
@ -163,7 +163,7 @@ def _token_exchange_spec(server: MCPServer, resource: str) -> ServerSpec | None:
|
|||
normalizes to ``rfc8693`` so a bad config value cannot crash spec-building. ``audience`` is
|
||||
forwarded only when the operator set it; a missing one is omitted, not derived.
|
||||
"""
|
||||
endpoint: Final = server.token_exchange_endpoint or server.token_url
|
||||
endpoint: Final = server.token_exchange_endpoint or server.effective_token_url
|
||||
if not server.client_id or not server.client_secret:
|
||||
return None
|
||||
profile: Final[Literal["rfc8693", "entra_obo"]] = (
|
||||
|
|
|
|||
|
|
@ -88,7 +88,10 @@ class AuthorizationCodeRefresher:
|
|||
if token.refresh_token is None:
|
||||
return None
|
||||
server: Final = self._server_lookup(server_id)
|
||||
if server is None or not server.token_url:
|
||||
if server is None:
|
||||
return None
|
||||
token_url: Final = server.effective_token_url
|
||||
if not token_url:
|
||||
return None
|
||||
|
||||
try:
|
||||
|
|
@ -106,7 +109,7 @@ class AuthorizationCodeRefresher:
|
|||
"refresh_token": token.refresh_token,
|
||||
**token_request.body,
|
||||
}
|
||||
body: Final = await self._token_endpoint(server.token_url, form, token_request.headers)
|
||||
body: Final = await self._token_endpoint(token_url, form, token_request.headers)
|
||||
if body is None:
|
||||
return None
|
||||
access_token: Final = body.get("access_token")
|
||||
|
|
|
|||
|
|
@ -183,6 +183,18 @@ class MCPServer(BaseModel):
|
|||
def __str__(self) -> str:
|
||||
return self.__repr__()
|
||||
|
||||
@property
|
||||
def effective_authorization_url(self) -> str | None:
|
||||
return self.authorization_url or self.configured_authorization_url
|
||||
|
||||
@property
|
||||
def effective_token_url(self) -> str | None:
|
||||
return self.token_url or self.configured_token_url
|
||||
|
||||
@property
|
||||
def effective_registration_url(self) -> str | None:
|
||||
return self.registration_url or self.configured_registration_url
|
||||
|
||||
@property
|
||||
def has_client_credentials(self) -> bool:
|
||||
"""True if this server should use the OAuth2 client_credentials (M2M) flow.
|
||||
|
|
|
|||
|
|
@ -579,3 +579,22 @@ def test_id_jag_honors_explicit_subject_token_type():
|
|||
def test_id_jag_half_configured_defers_to_v1(server):
|
||||
# A half-configured server must defer (None) rather than 500 at IdJagConfig construction.
|
||||
assert to_server_spec(server) is None
|
||||
|
||||
|
||||
def test_client_credentials_uses_admin_entered_token_url_when_issuer_yield_empties_resolved():
|
||||
"""A pinned issuer empties the resolved token_url while configured_token_url keeps the
|
||||
admin-entered value; the M2M spec must carry it so egress can mint."""
|
||||
spec = to_server_spec(
|
||||
_server(
|
||||
auth_type=MCPAuth.oauth2,
|
||||
oauth2_flow="client_credentials",
|
||||
url="https://up.example.com/mcp",
|
||||
token_url=None,
|
||||
configured_token_url="https://idp.example.com/token",
|
||||
client_id="cid",
|
||||
client_secret="csec",
|
||||
)
|
||||
)
|
||||
assert spec is not None
|
||||
assert isinstance(spec.config, ClientCredentialsConfig)
|
||||
assert spec.config.token_url == "https://idp.example.com/token"
|
||||
|
|
|
|||
|
|
@ -20,8 +20,10 @@ class _Server:
|
|||
upstream_resource=None,
|
||||
url=None,
|
||||
server_id="srv",
|
||||
configured_token_url=None,
|
||||
):
|
||||
self.token_url = token_url
|
||||
self.configured_token_url = configured_token_url
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.token_endpoint_auth_method = token_endpoint_auth_method
|
||||
|
|
@ -29,6 +31,10 @@ class _Server:
|
|||
self.url = url
|
||||
self.server_id = server_id
|
||||
|
||||
@property
|
||||
def effective_token_url(self):
|
||||
return self.token_url or self.configured_token_url
|
||||
|
||||
|
||||
def _lookup(server):
|
||||
return lambda server_id: server
|
||||
|
|
@ -262,3 +268,22 @@ async def test_returned_scope_overrides_prior_when_present():
|
|||
assert token is not None
|
||||
assert token.scopes == ("read",) # a present scope replaces the prior grant
|
||||
assert persisted[0][5] == ("read",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_uses_admin_entered_token_url_when_issuer_yield_empties_resolved():
|
||||
"""A pinned issuer empties the resolved token_url while configured_token_url keeps the
|
||||
admin-entered value; the refresh grant must POST there instead of silently failing."""
|
||||
posted = []
|
||||
refresher = _refresher(
|
||||
server=_Server(token_url=None, configured_token_url="https://idp.example.com/token"),
|
||||
body={"access_token": "new-at", "expires_in": 3600},
|
||||
post_sink=posted,
|
||||
)
|
||||
token = await refresher.refresh(
|
||||
"alice", "srv", OAuthToken(access_token="old", refresh_token="old-rt")
|
||||
)
|
||||
|
||||
assert token is not None
|
||||
assert token.access_token == "new-at"
|
||||
assert posted[0][0] == "https://idp.example.com/token"
|
||||
|
|
|
|||
|
|
@ -1337,3 +1337,28 @@ def test_mcp_oauth_token_identity_changes_when_only_upstream_resource_is_edited(
|
|||
assert mcp_oauth_token_identity(set_to_explicit) == mcp_oauth_token_identity(
|
||||
_identity_server(credentials={**creds, "upstream_resource": "api://audience-one"})
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_user_oauth_token_uses_admin_entered_token_url_when_issuer_yield_empties_resolved(monkeypatch):
|
||||
"""A pinned issuer empties the resolved token_url while configured_token_url keeps the
|
||||
admin-entered value; the silent per-user refresh must POST there instead of bailing."""
|
||||
from litellm.proxy._types import MCPTransport
|
||||
from litellm.types.mcp import MCPAuth
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
||||
server = MCPServer(
|
||||
server_id="srv-1",
|
||||
name="test",
|
||||
url="https://up.example.com/mcp",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2,
|
||||
client_id="cid",
|
||||
client_secret="csec",
|
||||
token_url=None,
|
||||
configured_token_url="https://idp.example.com/token",
|
||||
)
|
||||
result, captured = await _run_refresh(monkeypatch, server)
|
||||
|
||||
assert result is not None
|
||||
assert captured["url"] == "https://idp.example.com/token"
|
||||
|
|
|
|||
|
|
@ -79,24 +79,23 @@ def _resolved_oauth_metadata():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorize_resolves_cold_oauth_metadata():
|
||||
async def test_authorize_resolves_cold_oauth_metadata(monkeypatch):
|
||||
"""The route hands the registered server to the flow, whose deferred-discovery join resolves
|
||||
the cold metadata; the redirect must land on the discovered authorization endpoint."""
|
||||
from litellm.proxy._experimental.mcp_server import discoverable_endpoints
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
||||
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-for-lit-6255")
|
||||
server = _unresolved_oauth_server()
|
||||
global_mcp_server_manager.registry[server.server_id] = server
|
||||
global_mcp_server_manager._set_oauth_discovery_deferred(server.server_id, True)
|
||||
request = _mock_callback_request("https://litellm.example.com/")
|
||||
expected = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
global_mcp_server_manager,
|
||||
"_discover_oauth_metadata_for_server",
|
||||
new=AsyncMock(return_value=_resolved_oauth_metadata()),
|
||||
) as discovery,
|
||||
patch.object(discoverable_endpoints, "authorize_with_server", new=AsyncMock(return_value=expected)) as relay,
|
||||
):
|
||||
with patch.object(
|
||||
global_mcp_server_manager,
|
||||
"_discover_oauth_metadata_for_server",
|
||||
new=AsyncMock(return_value=_resolved_oauth_metadata()),
|
||||
) as discovery:
|
||||
response = await discoverable_endpoints.authorize(
|
||||
request=request,
|
||||
client_id="client-id",
|
||||
|
|
@ -105,12 +104,14 @@ async def test_authorize_resolves_cold_oauth_metadata():
|
|||
)
|
||||
|
||||
discovery.assert_awaited_once_with(server)
|
||||
assert relay.await_args.kwargs["mcp_server"].authorization_url == "https://idp.example.com/authorize"
|
||||
assert response is expected
|
||||
assert response.status_code == 307
|
||||
assert response.headers["location"].startswith("https://idp.example.com/authorize")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_resolves_cold_oauth_metadata():
|
||||
"""The route hands the registered server to the exchange, whose deferred-discovery join
|
||||
resolves the cold metadata; the exchange must post to the discovered token endpoint."""
|
||||
from litellm.proxy._experimental.mcp_server import discoverable_endpoints
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
||||
|
||||
|
|
@ -118,7 +119,11 @@ async def test_token_resolves_cold_oauth_metadata():
|
|||
global_mcp_server_manager.registry[server.server_id] = server
|
||||
global_mcp_server_manager._set_oauth_discovery_deferred(server.server_id, True)
|
||||
request = _mock_callback_request("https://litellm.example.com/")
|
||||
expected = MagicMock()
|
||||
fake_http_response = MagicMock()
|
||||
fake_http_response.json.return_value = {"access_token": "tok", "token_type": "Bearer"}
|
||||
fake_http_response.raise_for_status = MagicMock()
|
||||
fake_http_client = MagicMock()
|
||||
fake_http_client.post = AsyncMock(return_value=fake_http_response)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
|
|
@ -127,8 +132,10 @@ async def test_token_resolves_cold_oauth_metadata():
|
|||
new=AsyncMock(return_value=_resolved_oauth_metadata()),
|
||||
) as discovery,
|
||||
patch.object(
|
||||
discoverable_endpoints, "exchange_token_with_server", new=AsyncMock(return_value=expected)
|
||||
) as relay,
|
||||
discoverable_endpoints,
|
||||
"get_async_httpx_client",
|
||||
new=lambda llm_provider: fake_http_client,
|
||||
),
|
||||
):
|
||||
response = await discoverable_endpoints.token_endpoint(
|
||||
request=request,
|
||||
|
|
@ -139,20 +146,26 @@ async def test_token_resolves_cold_oauth_metadata():
|
|||
)
|
||||
|
||||
discovery.assert_awaited_once_with(server)
|
||||
assert relay.await_args.kwargs["mcp_server"].token_url == "https://idp.example.com/token"
|
||||
assert response is expected
|
||||
assert response.status_code == 200
|
||||
assert fake_http_client.post.await_args.args[0] == "https://idp.example.com/token"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_resolves_cold_oauth_metadata():
|
||||
"""The route hands the registered server to the registration flow, whose deferred-discovery
|
||||
join resolves the cold metadata; DCR must post to the discovered registration endpoint."""
|
||||
from litellm.proxy._experimental.mcp_server import discoverable_endpoints
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
||||
|
||||
server = _unresolved_oauth_server()
|
||||
server = _unresolved_oauth_server().model_copy(update={"client_id": None})
|
||||
global_mcp_server_manager.registry[server.server_id] = server
|
||||
global_mcp_server_manager._set_oauth_discovery_deferred(server.server_id, True)
|
||||
request = _mock_callback_request("https://litellm.example.com/")
|
||||
expected = MagicMock()
|
||||
fake_http_response = MagicMock()
|
||||
fake_http_response.json.return_value = {"client_id": "generated-client", "client_secret": "generated-secret"}
|
||||
fake_http_response.raise_for_status = MagicMock()
|
||||
fake_http_client = MagicMock()
|
||||
fake_http_client.post = AsyncMock(return_value=fake_http_response)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
|
|
@ -162,14 +175,135 @@ async def test_register_resolves_cold_oauth_metadata():
|
|||
) as discovery,
|
||||
patch.object(discoverable_endpoints, "_read_request_body", new=AsyncMock(return_value={})),
|
||||
patch.object(
|
||||
discoverable_endpoints, "register_client_with_server", new=AsyncMock(return_value=expected)
|
||||
) as relay,
|
||||
discoverable_endpoints,
|
||||
"get_async_httpx_client",
|
||||
new=lambda llm_provider: fake_http_client,
|
||||
),
|
||||
):
|
||||
response = await discoverable_endpoints.register_client(request=request, mcp_server_name=server.server_name)
|
||||
|
||||
discovery.assert_awaited_once_with(server)
|
||||
assert relay.await_args.kwargs["mcp_server"].registration_url == "https://idp.example.com/register"
|
||||
assert response is expected
|
||||
assert response.status_code == 200
|
||||
assert fake_http_client.post.await_args.args[0] == "https://idp.example.com/register"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_route_bridge_missing_registration_url_joins_discovery():
|
||||
"""A clientless DCR bridge whose authorize and token urls are admin-entered still relays
|
||||
registration upstream: the flow must join deferred discovery for the missing registration
|
||||
endpoint instead of short-circuiting to dummy credentials because authorization resolves."""
|
||||
import json
|
||||
|
||||
from litellm.proxy._experimental.mcp_server import discoverable_endpoints
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
||||
from litellm.proxy._types import MCPTransport
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
||||
server = MCPServer(
|
||||
server_id="bridge-partial-metadata",
|
||||
name="bridge_partial_metadata",
|
||||
server_name="bridge_partial_metadata",
|
||||
alias="bridge_partial_metadata",
|
||||
url="https://mcp.example.com/mcp",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth_delegate,
|
||||
dcr_bridge=True,
|
||||
client_id=None,
|
||||
authorization_url="https://idp.example.com/authorize",
|
||||
token_url="https://idp.example.com/token",
|
||||
)
|
||||
global_mcp_server_manager.registry[server.server_id] = server
|
||||
global_mcp_server_manager._set_oauth_discovery_deferred(server.server_id, True)
|
||||
request = _mock_callback_request("https://litellm.example.com/")
|
||||
fake_http_response = MagicMock()
|
||||
fake_http_response.json.return_value = {"client_id": "generated-client", "client_secret": "generated-secret"}
|
||||
fake_http_response.raise_for_status = MagicMock()
|
||||
fake_http_client = MagicMock()
|
||||
fake_http_client.post = AsyncMock(return_value=fake_http_response)
|
||||
|
||||
with (
|
||||
patch.object( # test-quality-ok: innermost discovery seam on a module-global manager; the route-to-flow join under test stays real
|
||||
global_mcp_server_manager,
|
||||
"_discover_oauth_metadata_for_server",
|
||||
new=AsyncMock(return_value=_resolved_oauth_metadata()),
|
||||
) as discovery,
|
||||
patch.object( # test-quality-ok: the MagicMock Request carries no body; this seam feeds the RFC 7591 redirect_uris
|
||||
discoverable_endpoints,
|
||||
"_read_request_body",
|
||||
new=AsyncMock(return_value={"redirect_uris": ["https://client.example.com/cb"]}),
|
||||
),
|
||||
patch.object( # test-quality-ok: keeps the DCR POST off the network so its target URL can be asserted
|
||||
discoverable_endpoints,
|
||||
"get_async_httpx_client",
|
||||
new=lambda llm_provider: fake_http_client,
|
||||
),
|
||||
):
|
||||
response = await discoverable_endpoints.register_client(request=request, mcp_server_name=server.server_name)
|
||||
|
||||
discovery.assert_awaited_once_with(server)
|
||||
assert fake_http_client.post.await_args.args[0] == "https://idp.example.com/register"
|
||||
assert fake_http_client.post.await_args.kwargs["json"]["redirect_uris"] == ["https://client.example.com/cb"]
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.body.decode("utf-8"))["client_id"] == "generated-client"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_route_bridge_missing_registration_url_joins_discovery():
|
||||
"""A clientless DCR bridge rebuilt with an admin-entered token url but without its discovered
|
||||
registration endpoint must rejoin discovery at the exchange: the relay-vs-callback arm hinges
|
||||
on the registration url, so skipping discovery would swap the client's own redirect_uri for
|
||||
the gateway callback and the upstream would reject the code."""
|
||||
from litellm.proxy._experimental.mcp_server import discoverable_endpoints
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager
|
||||
from litellm.proxy._types import MCPTransport
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
||||
server = MCPServer(
|
||||
server_id="bridge-partial-token-metadata",
|
||||
name="bridge_partial_token_metadata",
|
||||
server_name="bridge_partial_token_metadata",
|
||||
alias="bridge_partial_token_metadata",
|
||||
url="https://mcp.example.com/mcp",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.true_passthrough,
|
||||
dcr_bridge=True,
|
||||
client_id=None,
|
||||
authorization_url="https://idp.example.com/authorize",
|
||||
token_url="https://idp.example.com/token",
|
||||
)
|
||||
global_mcp_server_manager.registry[server.server_id] = server
|
||||
global_mcp_server_manager._set_oauth_discovery_deferred(server.server_id, True)
|
||||
request = _mock_callback_request("https://litellm.example.com/")
|
||||
fake_http_response = MagicMock()
|
||||
fake_http_response.json.return_value = {"access_token": "tok", "token_type": "Bearer"}
|
||||
fake_http_response.raise_for_status = MagicMock()
|
||||
fake_http_client = MagicMock()
|
||||
fake_http_client.post = AsyncMock(return_value=fake_http_response)
|
||||
|
||||
with (
|
||||
patch.object( # test-quality-ok: innermost discovery seam on a module-global manager; the route-to-exchange join under test stays real
|
||||
global_mcp_server_manager,
|
||||
"_discover_oauth_metadata_for_server",
|
||||
new=AsyncMock(return_value=_resolved_oauth_metadata()),
|
||||
) as discovery,
|
||||
patch.object( # test-quality-ok: keeps the upstream token POST off the network so its redirect_uri arm can be asserted
|
||||
discoverable_endpoints,
|
||||
"get_async_httpx_client",
|
||||
new=lambda llm_provider: fake_http_client,
|
||||
),
|
||||
):
|
||||
response = await discoverable_endpoints.token_endpoint(
|
||||
request=request,
|
||||
grant_type="authorization_code",
|
||||
code="upstream-code",
|
||||
redirect_uri="https://client.example.com/cb",
|
||||
client_id="dcr-client-id",
|
||||
mcp_server_name=server.server_name,
|
||||
)
|
||||
|
||||
discovery.assert_awaited_once_with(server)
|
||||
assert response.status_code == 200
|
||||
assert fake_http_client.post.await_args.kwargs["data"]["redirect_uri"] == "https://client.example.com/cb"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -8878,6 +9012,272 @@ async def test_authorize_wall_names_the_issuer_for_anchored_servers():
|
|||
assert "idp.example.com" not in detail_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorize_uses_admin_entered_github_oauth_urls_after_issuer_yield(monkeypatch):
|
||||
"""GitHub MCP servers store Authorization URL and Token URL on the row. 1.99 can empty
|
||||
the resolved authorization_url when a leftover issuer is treated as a pin (RFC 8414
|
||||
yield). The UI authorize must still redirect to the admin-entered GitHub authorize URL
|
||||
instead of 400ing that discovery against api.githubcopilot.com failed."""
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
authorize_with_server,
|
||||
)
|
||||
from litellm.types.mcp import MCPAuth, MCPTransport
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
||||
server = MCPServer(
|
||||
server_id="ecac50c4-8eca-438a-af80-9bdebadafc69",
|
||||
name="github_mcp",
|
||||
alias="github_mcp",
|
||||
server_name="github_mcp",
|
||||
url="https://api.githubcopilot.com/mcp/",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2,
|
||||
oauth2_flow="authorization_code",
|
||||
client_id="github-app-client",
|
||||
authorization_url=None,
|
||||
token_url=None,
|
||||
issuer="https://github.com",
|
||||
issuer_is_anchored=True,
|
||||
configured_authorization_url="https://github.com/login/oauth/authorize",
|
||||
configured_token_url="https://github.com/login/oauth/access_token",
|
||||
)
|
||||
mock_request = MagicMock()
|
||||
mock_request.base_url = "https://litellm.example.com/"
|
||||
mock_request.headers = {}
|
||||
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-for-lit-6255")
|
||||
response = await authorize_with_server(
|
||||
request=mock_request,
|
||||
mcp_server=server,
|
||||
client_id="github-app-client",
|
||||
redirect_uri="http://127.0.0.1:60108/callback",
|
||||
state="state123",
|
||||
)
|
||||
|
||||
assert response.status_code == 307
|
||||
assert "https://github.com/login/oauth/authorize" in response.headers["location"]
|
||||
assert "client_id=github-app-client" in response.headers["location"]
|
||||
|
||||
|
||||
def test_oauth_endpoints_count_admin_entered_urls_as_resolved():
|
||||
"""A leftover issuer empties the resolved authorize/token fields but must not keep the
|
||||
server on the deferred-discovery retry path when the admin already stored those URLs."""
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
||||
_oauth_endpoints_unresolved,
|
||||
)
|
||||
from litellm.types.mcp import MCPAuth, MCPTransport
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
||||
server = MCPServer(
|
||||
server_id="github-configured",
|
||||
name="github_mcp",
|
||||
url="https://api.githubcopilot.com/mcp/",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2,
|
||||
oauth2_flow="authorization_code",
|
||||
authorization_url=None,
|
||||
token_url=None,
|
||||
configured_authorization_url="https://github.com/login/oauth/authorize",
|
||||
configured_token_url="https://github.com/login/oauth/access_token",
|
||||
)
|
||||
assert _oauth_endpoints_unresolved(server) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_exchange_with_configured_token_url_never_joins_discovery(monkeypatch):
|
||||
"""A server can hold an admin-entered Token URL while its Authorization URL is absent. The
|
||||
token exchange must post to that stored endpoint without awaiting deferred discovery, which
|
||||
can 503 against an unreachable issuer even though nothing it resolves is needed here."""
|
||||
from litellm.proxy._experimental.mcp_server import (
|
||||
discoverable_endpoints,
|
||||
mcp_server_manager,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
exchange_token_with_server,
|
||||
)
|
||||
from litellm.types.mcp import MCPAuth, MCPTransport
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
||||
server = MCPServer(
|
||||
server_id="token-url-only",
|
||||
name="token_url_only",
|
||||
server_name="token_url_only",
|
||||
alias="token_url_only",
|
||||
url="https://mcp.example.com/mcp/",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2,
|
||||
client_id="cid",
|
||||
client_secret="cs",
|
||||
authorization_url=None,
|
||||
token_url=None,
|
||||
issuer="https://idp.example.com",
|
||||
issuer_is_anchored=True,
|
||||
configured_token_url="https://idp.example.com/oauth/token",
|
||||
)
|
||||
|
||||
async def fail_discovery(_srv):
|
||||
raise AssertionError("the exchange joined deferred discovery despite a stored token url")
|
||||
|
||||
monkeypatch.setattr(
|
||||
mcp_server_manager.global_mcp_server_manager,
|
||||
"ensure_oauth_metadata_discovered",
|
||||
fail_discovery,
|
||||
)
|
||||
fake_http_response = MagicMock()
|
||||
fake_http_response.json.return_value = {"access_token": "tok", "token_type": "Bearer"}
|
||||
fake_http_response.raise_for_status = MagicMock()
|
||||
fake_http_client = MagicMock()
|
||||
fake_http_client.post = AsyncMock(return_value=fake_http_response)
|
||||
monkeypatch.setattr(
|
||||
discoverable_endpoints,
|
||||
"get_async_httpx_client",
|
||||
lambda llm_provider: fake_http_client,
|
||||
)
|
||||
mock_request = MagicMock()
|
||||
mock_request.base_url = "https://litellm.example.com/"
|
||||
mock_request.headers = {}
|
||||
|
||||
response = await exchange_token_with_server(
|
||||
request=mock_request,
|
||||
mcp_server=server,
|
||||
grant_type="authorization_code",
|
||||
code="upstream-code",
|
||||
redirect_uri="http://127.0.0.1:3000/cb",
|
||||
client_id="cid",
|
||||
client_secret=None,
|
||||
code_verifier=None,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert fake_http_client.post.await_args.args[0] == "https://idp.example.com/oauth/token"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_root_token_route_with_configured_token_url_never_joins_discovery(monkeypatch):
|
||||
"""A root POST /token that falls back to the sole OAuth2 server must reach the exchange's
|
||||
endpoint-gated discovery join instead of awaiting full discovery at the route: with the
|
||||
token url admin-entered, a failing or slow discovery must not turn the exchange into a 503."""
|
||||
from litellm.proxy._experimental.mcp_server import (
|
||||
discoverable_endpoints,
|
||||
mcp_server_manager,
|
||||
)
|
||||
from litellm.types.mcp import MCPAuth, MCPTransport
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
||||
manager = mcp_server_manager.global_mcp_server_manager
|
||||
server = MCPServer(
|
||||
server_id="sole-token-url-only",
|
||||
name="sole_token_url_only",
|
||||
server_name="sole_token_url_only",
|
||||
alias="sole_token_url_only",
|
||||
url="https://mcp.example.com/mcp/",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth2,
|
||||
client_id="cid",
|
||||
client_secret="cs",
|
||||
issuer="https://idp.example.com",
|
||||
issuer_is_anchored=True,
|
||||
configured_token_url="https://idp.example.com/oauth/token",
|
||||
)
|
||||
saved_registry = dict(manager.registry)
|
||||
manager.registry.clear()
|
||||
manager.registry[server.server_id] = server
|
||||
manager._set_oauth_discovery_deferred(server.server_id, True)
|
||||
|
||||
async def fail_discovery(_srv):
|
||||
raise AssertionError("the root token route joined deferred discovery despite a stored token url")
|
||||
|
||||
monkeypatch.setattr(manager, "ensure_oauth_metadata_discovered", fail_discovery)
|
||||
fake_http_response = MagicMock()
|
||||
fake_http_response.json.return_value = {"access_token": "tok", "token_type": "Bearer"}
|
||||
fake_http_response.raise_for_status = MagicMock()
|
||||
fake_http_client = MagicMock()
|
||||
fake_http_client.post = AsyncMock(return_value=fake_http_response)
|
||||
monkeypatch.setattr(
|
||||
discoverable_endpoints,
|
||||
"get_async_httpx_client",
|
||||
lambda llm_provider: fake_http_client,
|
||||
)
|
||||
request = _mock_callback_request("https://litellm.example.com/")
|
||||
|
||||
try:
|
||||
response = await discoverable_endpoints.token_endpoint(
|
||||
request=request,
|
||||
grant_type="authorization_code",
|
||||
code="upstream-code",
|
||||
redirect_uri="http://127.0.0.1:3000/cb",
|
||||
client_id="unregistered-dcr-client",
|
||||
)
|
||||
finally:
|
||||
manager.registry.clear()
|
||||
manager.registry.update(saved_registry)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert fake_http_client.post.await_args.args[0] == "https://idp.example.com/oauth/token"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bridge_authorize_relays_with_registration_url_resolved_by_deferred_discovery(monkeypatch):
|
||||
"""When deferred discovery resolves a DCR-bridge server during the authorize request, the
|
||||
relay-vs-short-circuit call must read the resolved server: a client that registered itself
|
||||
through the front door keeps its own redirect binding instead of being routed through the
|
||||
gateway callback the upstream never granted it."""
|
||||
from litellm.proxy._experimental.mcp_server import mcp_server_manager
|
||||
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
|
||||
authorize_with_server,
|
||||
)
|
||||
from litellm.types.mcp import MCPAuth, MCPTransport
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
||||
server = MCPServer(
|
||||
server_id="bridge-deferred",
|
||||
name="bridge_deferred",
|
||||
server_name="bridge_deferred",
|
||||
alias="bridge_deferred",
|
||||
url="https://mcp.example.com/mcp/",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.true_passthrough,
|
||||
dcr_bridge=True,
|
||||
authorization_url=None,
|
||||
token_url=None,
|
||||
registration_url=None,
|
||||
)
|
||||
resolved = server.model_copy(
|
||||
update={
|
||||
"authorization_url": "https://idp.example.com/oauth/authorize",
|
||||
"token_url": "https://idp.example.com/oauth/token",
|
||||
"registration_url": "https://idp.example.com/oauth/register",
|
||||
}
|
||||
)
|
||||
|
||||
async def resolve_discovery(_srv):
|
||||
return resolved
|
||||
|
||||
monkeypatch.setattr(
|
||||
mcp_server_manager.global_mcp_server_manager,
|
||||
"ensure_oauth_metadata_discovered",
|
||||
resolve_discovery,
|
||||
)
|
||||
mock_request = MagicMock()
|
||||
mock_request.base_url = "https://litellm.example.com/"
|
||||
mock_request.headers = {}
|
||||
|
||||
response = await authorize_with_server(
|
||||
request=mock_request,
|
||||
mcp_server=server,
|
||||
client_id="front-door-client",
|
||||
redirect_uri="http://127.0.0.1:60110/client-callback",
|
||||
state="state456",
|
||||
code_challenge="E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||
code_challenge_method="S256",
|
||||
)
|
||||
|
||||
assert response.status_code == 307
|
||||
location = response.headers["location"]
|
||||
assert location.startswith("https://idp.example.com/oauth/authorize")
|
||||
assert "redirect_uri=http%3A%2F%2F127.0.0.1%3A60110%2Fclient-callback" in location
|
||||
|
||||
|
||||
def test_passthrough_authorization_code_round_trips_and_rejects_hostile_input():
|
||||
"""The passthrough gateway code seals and recovers the ephemeral DCR client and upstream code,
|
||||
and is total over hostile input: a raw upstream code opens to None, and a tampered or
|
||||
|
|
|
|||
|
|
@ -392,3 +392,22 @@ async def test_invalidate_clears_every_identity_for_a_server():
|
|||
|
||||
assert refetched == "tok-after-invalidate"
|
||||
assert mock_client.post.call_count == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_m2m_mint_uses_admin_entered_token_url_when_issuer_yield_empties_resolved():
|
||||
"""A pinned issuer empties the resolved token_url while configured_token_url keeps the
|
||||
admin-entered value; the client_credentials mint must POST there instead of raising."""
|
||||
server = _server(token_url=None, configured_token_url="https://auth.example.com/token")
|
||||
cache = MCPOAuth2TokenCache()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post.return_value = _token_response("m2m-token-configured")
|
||||
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.oauth2_token_cache.get_async_httpx_client",
|
||||
return_value=mock_client,
|
||||
):
|
||||
result = await cache.async_get_token(server)
|
||||
|
||||
assert result == "m2m-token-configured"
|
||||
assert mock_client.post.call_args[0][0] == "https://auth.example.com/token"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue