Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
64868f2
feat(mcp): aggregate gateway DCR discovery front door behind mcp_gate…
tin-berri Jul 14, 2026
943d7aa
refactor(mcp): make the aggregate DCR front door always-on, remove th…
tin-berri Jul 15, 2026
e7e5265
fix(mcp): reserve mcp for the aggregate AS and root-path the discover…
tin-berri Jul 15, 2026
90195af
test(mcp): isolate MCP discovery tests from a leaked SERVER_ROOT_PATH
tin-berri Jul 15, 2026
5b2877f
feat(mcp): identity-only session tokens for the gateway DCR front door
tin-berri Jul 14, 2026
6e72386
feat(mcp): add jti claim for per-mint session token uniqueness
tin-berri Jul 14, 2026
22a7174
fix(ui): stamp exp on the UI session cookie so bounded-lifetime reade…
tin-berri Jul 14, 2026
f546cfc
feat(mcp): aggregate DCR register, authorize, complete, and token flo…
tin-berri Jul 14, 2026
05c55d0
refactor(mcp): harden gateway DCR flow per adversarial review
tin-berri Jul 14, 2026
4e9ffb8
feat(mcp): admit gateway DCR session bearers at the aggregate /mcp scope
tin-berri Jul 14, 2026
1b28128
refactor(mcp): bind org_id on session admission and clarify unreachab…
tin-berri Jul 14, 2026
b82fb75
feat(mcp): union team-inherited MCP grants across all of a user's tea…
tin-berri Jul 14, 2026
5df5fa5
fix(mcp): gate the team-grant union on an admission marker, not api_k…
tin-berri Jul 14, 2026
262e93e
feat(ui): connect-flow interlude banner on the MCP apps grid for gate…
tin-berri Jul 14, 2026
5ab7a85
feat(ui): simplify DCR connect grid, auto-finish on tab close, fix co…
tin-berri Jul 15, 2026
6c74e9b
feat(ui): grey out client-forwarded and OBO MCP servers on the gatewa…
tin-berri Jul 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
370 changes: 310 additions & 60 deletions litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py

Large diffs are not rendered by default.

178 changes: 161 additions & 17 deletions litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
_finish_bridge_mint,
_prepare_bridge_mint,
_prepare_bridge_refresh,
_reload_active_user_by_id,
)
from litellm.proxy._experimental.mcp_server.faults import (
CallerRejected,
Expand All @@ -39,18 +40,26 @@
dcr_fault_detail,
render_token_fault,
)
from litellm.proxy._experimental.mcp_server.gateway_dcr_flow import (
aggregate_authorize,
aggregate_token,
complete_connect_flow,
is_gateway_dcr_client_id,
register_aggregate_client,
relative_request_url,
)
from litellm.proxy._experimental.mcp_server.oauth_utils import (
TOKEN_NO_CACHE_HEADERS,
get_request_base_url,
validate_trusted_redirect_uri,
well_known_root_suffix,
)
from litellm.proxy.auth.ip_address_utils import IPAddressUtils
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
decrypt_value_helper,
encrypt_value_helper,
)
from litellm.proxy.common_utils.http_parsing_utils import _read_request_body
from litellm.proxy.utils import get_server_root_path
from litellm.types.mcp import MCPAuth, MCPCredentials
from litellm.types.mcp_server.mcp_server_manager import MCPServer

Expand Down Expand Up @@ -217,14 +226,25 @@ def open_bridge_authorization_code(code: str) -> _BridgeAuthorizationCode | None
return None


def _session_cookie_user_id(request: Request) -> str | None:
"""The signed-in litellm user for a browser request, or ``None``. Thin wrapper so the
aggregate DCR flow's verbs receive the identity as a plain value instead of parsing
cookies themselves."""
from litellm.proxy._experimental.mcp_server.byok_oauth_endpoints import ( # noqa: PLC0415 # circular import at module load
_user_id_from_session_cookie,
)

return _user_id_from_session_cookie(request)


def _redirect_to_litellm_login(request: Request) -> RedirectResponse:
"""Send an unauthenticated browser through litellm login before the interactive bridge authorize
can capture its identity. The bridge oauth_delegate flow seals the SSO user into the gateway code,
so a session is required; without one there is nothing to bind. After login the user re-initiates
the connection, which then finds the session cookie (the seamless return-to round-trip, which is
origin-validated against the control-plane URL, is a follow-up)."""
so a session is required; without one there is nothing to bind. A same-origin relative
``return_to`` (honored by the SSO callback) brings the browser straight back to this authorize
request after login instead of stranding it on the dashboard."""
base_url = get_request_base_url(request)
return RedirectResponse(f"{base_url}/sso/key/generate")
return RedirectResponse(f"{base_url}/sso/key/generate?{urlencode({'return_to': relative_request_url(request)})}")


# LIT-4197: some upstream authorization servers reject an over-long ``state``
Expand Down Expand Up @@ -1253,6 +1273,18 @@ async def authorize(
global_mcp_server_manager,
)

if mcp_server_name is None and client_id and is_gateway_dcr_client_id(client_id):
return aggregate_authorize(
request=request,
client_id=client_id,
redirect_uri=redirect_uri,
state=state,
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
response_type=response_type,
session_user_id=_session_cookie_user_id(request),
)

lookup_name: Optional[str] = mcp_server_name or client_id
client_ip = IPAddressUtils.get_mcp_client_ip(request)
mcp_server = (
Expand Down Expand Up @@ -1316,6 +1348,25 @@ async def token_endpoint(
global_mcp_server_manager,
)

if mcp_server_name is None and is_gateway_dcr_client_id(client_id):
from litellm.proxy.proxy_server import ( # noqa: PLC0415 # circular import at module load
master_key,
user_api_key_cache,
)

return await aggregate_token(
request=request,
grant_type=grant_type,
code=code,
redirect_uri=redirect_uri,
client_id=client_id,
code_verifier=code_verifier,
refresh_token=refresh_token,
master_key=master_key,
reload_user=_reload_active_user_by_id,
cache=user_api_key_cache,
)

lookup_name = mcp_server_name or client_id
client_ip = IPAddressUtils.get_mcp_client_ip(request)
mcp_server = global_mcp_server_manager.get_mcp_server_by_name(lookup_name, client_ip=client_ip)
Expand All @@ -1337,6 +1388,21 @@ async def token_endpoint(
)


@router.post("/authorize/complete")
async def authorize_complete(request: Request, flow: str = Form(...)):
"""Finish an aggregate connect flow: mint the gateway authorization code for the
signed-in user and redirect back to the DCR client. POST plus the per-flow HttpOnly
cookie set at /authorize; an anonymous or bad-flow request just 400s."""
from litellm.proxy.proxy_server import user_api_key_cache # noqa: PLC0415 # circular import at module load

return await complete_connect_flow(
request=request,
flow_handle=flow,
session_user_id=_session_cookie_user_id(request),
cache=user_api_key_cache,
)


# Per RFC 6749 §4.1.2.1, an IdP that rejects an OAuth authorization request
# redirects back to the configured redirect URI with ``error`` /
# ``error_description`` / ``error_uri`` query params and no ``code``. The MCP
Expand Down Expand Up @@ -1770,11 +1836,88 @@ def _jwt_auth_issuers() -> list:
return issuers


def _build_aggregate_protected_resource_response(request: Request) -> dict:
"""RFC 9728 metadata for the aggregate /mcp resource: the gateway itself is
the authorization server. No per-server names or scopes leak here; access
is resolved after sign-in from the authenticated user's grants.

The advertised authorization server is ``{base}/mcp`` (not the bare
origin) so RFC 8414 path-insertion resolves its metadata at
``/.well-known/oauth-authorization-server/mcp``, a route this module
owns. The bare-origin well-known is registered first by the BYOK OAuth
feature and describes the BYOK flow, so it must not be the aggregate
discovery entry point (same pattern as the per-server documents, which
advertise ``{base}/{server_name}``)."""
request_base_url = get_request_base_url(request)
return {
"authorization_servers": [f"{request_base_url}/mcp"],
"resource": f"{request_base_url}/mcp",
"scopes_supported": [],
}


def _build_aggregate_authorization_server_response(request: Request) -> dict:
"""RFC 8414 metadata for the gateway as the aggregate authorization server.

The issuer is ``{base}/mcp`` and must stay equal to the value the
aggregate protected-resource document advertises: spec clients verify the
issuer in the metadata matches the one that derived the well-known URL.
Advertises the root /authorize, /token, and /register endpoints and
``token_endpoint_auth_methods_supported: ["none", ...]`` because DCR
clients (Claude Desktop, MCP Inspector) register as public clients; PKCE
S256 is mandatory in the gateway's authorize flow."""
request_base_url = get_request_base_url(request)
return {
"issuer": f"{request_base_url}/mcp",
"authorization_endpoint": f"{request_base_url}/authorize",
"token_endpoint": f"{request_base_url}/token",
"registration_endpoint": f"{request_base_url}/register",
"response_types_supported": ["code"],
"scopes_supported": [],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none", "client_secret_post"],
}


# RFC 9728 path-appended discovery for the aggregate /mcp endpoint. A client
# pointed at {base}/mcp inserts the well-known segment before the resource
# path, so this exact route must exist for aggregate discovery to work at all.
# Declared before the parameterized well-known routes below: Starlette matches
# in registration order, and /.well-known/oauth-authorization-server/{name}
# would otherwise capture the "/mcp" suffix as a server name.
@router.get(f"/.well-known/oauth-protected-resource{well_known_root_suffix()}/mcp")
async def oauth_protected_resource_aggregate(request: Request):
"""
OAuth protected resource discovery for the aggregate /mcp endpoint.

The single-segment ``/mcp`` path does not collide with any per-server PRM pattern
(those are two-segment: ``/mcp/{server}`` or ``/{server}/mcp``), so this unambiguously
describes the aggregate resource.
"""
return _build_aggregate_protected_resource_response(request)


@router.get(f"/.well-known/oauth-authorization-server{well_known_root_suffix()}/mcp")
async def oauth_authorization_server_aggregate(request: Request):
"""
OAuth authorization server discovery for the aggregate /mcp endpoint, the RFC 8414
path-inserted form for a client that treats {base}/mcp as its authorization base URL.

The single-segment /mcp is reserved for the aggregate so the discovery chain stays
consistent: the aggregate protected-resource document advertises {base}/mcp as its
authorization server, so the document served here must have issuer {base}/mcp. A server
literally named ``mcp`` therefore does not take this route; it keeps its standard
two-segment discovery at /.well-known/oauth-authorization-server/mcp/mcp. Letting the
per-server row win here instead would serve an issuer of {base} against a resource that
advertised {base}/mcp, which fails the RFC 8414 issuer check and breaks the front door.
"""
return _build_aggregate_authorization_server_response(request)


# Standard MCP pattern: /.well-known/oauth-protected-resource/mcp/{server_name}
# This is the pattern expected by standard MCP clients (mcp-inspector, VSCode Copilot)
@router.get(
f"/.well-known/oauth-protected-resource{'' if get_server_root_path() == '/' else get_server_root_path()}/mcp/{{mcp_server_name}}"
)
@router.get(f"/.well-known/oauth-protected-resource{well_known_root_suffix()}/mcp/{{mcp_server_name}}")
async def oauth_protected_resource_mcp_standard(request: Request, mcp_server_name: str):
"""
OAuth protected resource discovery endpoint using standard MCP URL pattern.
Expand All @@ -1794,9 +1937,7 @@ async def oauth_protected_resource_mcp_standard(request: Request, mcp_server_nam

# LiteLLM legacy pattern: /.well-known/oauth-protected-resource/{server_name}/mcp
# Kept for backward compatibility with existing deployments
@router.get(
f"/.well-known/oauth-protected-resource{'' if get_server_root_path() == '/' else get_server_root_path()}/{{mcp_server_name}}/mcp"
)
@router.get(f"/.well-known/oauth-protected-resource{well_known_root_suffix()}/{{mcp_server_name}}/mcp")
@router.get("/.well-known/oauth-protected-resource")
async def oauth_protected_resource_mcp(request: Request, mcp_server_name: Optional[str] = None):
"""
Expand Down Expand Up @@ -1866,9 +2007,7 @@ def _build_oauth_authorization_server_response(


# Standard MCP pattern: /.well-known/oauth-authorization-server/mcp/{server_name}
@router.get(
f"/.well-known/oauth-authorization-server{'' if get_server_root_path() == '/' else get_server_root_path()}/mcp/{{mcp_server_name}}"
)
@router.get(f"/.well-known/oauth-authorization-server{well_known_root_suffix()}/mcp/{{mcp_server_name}}")
async def oauth_authorization_server_mcp_standard(request: Request, mcp_server_name: str):
"""
OAuth authorization server discovery endpoint using standard MCP URL pattern.
Expand All @@ -1883,9 +2022,7 @@ async def oauth_authorization_server_mcp_standard(request: Request, mcp_server_n


# LiteLLM legacy pattern and root endpoint
@router.get(
f"/.well-known/oauth-authorization-server{'' if get_server_root_path() == '/' else get_server_root_path()}/{{mcp_server_name}}"
)
@router.get(f"/.well-known/oauth-authorization-server{well_known_root_suffix()}/{{mcp_server_name}}")
@router.get("/.well-known/oauth-authorization-server")
async def oauth_authorization_server_mcp(request: Request, mcp_server_name: Optional[str] = None):
"""
Expand Down Expand Up @@ -1990,6 +2127,13 @@ async def register_client(request: Request, mcp_server_name: Optional[str] = Non
}
client_ip = IPAddressUtils.get_mcp_client_ip(request)
if not mcp_server_name:
# A real DCR request carries redirect_uris (RFC 7591): route it to the aggregate DCR
# endpoint the aggregate authorization-server metadata advertises. A single-server
# deployment registers at /{server}/register instead (its bare-origin discovery
# advertises that), so this does not affect it. A request without redirect_uris is not
# a DCR request, so the legacy single-server-or-dummy fallback is kept for it.
if data.get("redirect_uris"):
return await register_aggregate_client(request_body=data)
resolved = _resolve_oauth2_server_for_root_endpoints(client_ip=client_ip)
if resolved:
return await register_client_with_server(
Expand Down
Loading
Loading