Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,8 @@
import litellm
from litellm._logging import verbose_logger
from litellm.proxy._experimental.mcp_server.oauth_utils import (
get_aggregate_resource_metadata_url,
get_passthrough_resource_metadata_url,
get_request_base_url,
well_known_root_suffix,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import (
BridgeEnvelopeAdmitted,
Expand Down Expand Up @@ -227,7 +226,7 @@ def _gateway_dcr_challenge(
resource_metadata_url = (
get_passthrough_resource_metadata_url(request.scope, target)
if target is not None
else f"{get_request_base_url(request)}/.well-known/oauth-protected-resource{well_known_root_suffix()}/mcp"
else get_aggregate_resource_metadata_url(request.scope)
)
error_attr = 'error="invalid_token", ' if invalid_token else ""
return HTTPException(
Expand Down
21 changes: 17 additions & 4 deletions litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
from litellm.proxy._experimental.mcp_server.oauth_utils import (
TOKEN_NO_CACHE_HEADERS,
build_upstream_oauth2_token_request,
get_oauth_discovery_base_url,
get_request_base_url,
resolve_upstream_resource,
validate_trusted_redirect_uri,
Expand Down Expand Up @@ -2120,6 +2121,14 @@ async def _build_oauth_protected_resource_response(
)

request_base_url = get_request_base_url(request)
# ``resource`` alone is bound by RFC 9728 §3 to exactly match the URL the
# client called, so only that value derives from the request path prefix.
# ``authorization_servers`` (and, downstream, ``authorization_endpoint`` /
# ``token_endpoint`` / ``registration_endpoint``) stay on the un-prefixed
# base because those handlers are only mounted at root-relative paths —
Comment thread
greptile-apps[bot] marked this conversation as resolved.
# advertising them under a request-derived prefix would 404 in the
# default deployment.
resource_base_url = get_oauth_discovery_base_url(request)
client_ip = IPAddressUtils.get_mcp_client_ip(request)
explicitly_named = mcp_server_name is not None

Expand All @@ -2137,12 +2146,12 @@ async def _build_oauth_protected_resource_response(
if mcp_server_name:
if use_standard_pattern:
# Standard MCP pattern: /mcp/{server_name}
resource_url = f"{request_base_url}/mcp/{mcp_server_name}"
resource_url = f"{resource_base_url}/mcp/{mcp_server_name}"
else:
# LiteLLM legacy pattern: /{server_name}/mcp
resource_url = f"{request_base_url}/{mcp_server_name}/mcp"
resource_url = f"{resource_base_url}/{mcp_server_name}/mcp"
else:
resource_url = f"{request_base_url}/mcp"
resource_url = f"{resource_base_url}/mcp"

if mcp_server is not None and mcp_server_name and mcp_server.is_dcr_bridge:
return {
Expand Down Expand Up @@ -2270,9 +2279,13 @@ def _build_aggregate_protected_resource_response(request: Request) -> dict:
discovery entry point (same pattern as the per-server documents, which
advertise ``{base}/{server_name}``)."""
request_base_url = get_request_base_url(request)
# Only ``resource`` derives from the request path prefix (RFC 9728 §3
# exact-match); ``authorization_servers`` stays on the un-prefixed base
# because the AS handlers are mounted only at root-relative paths.
resource_base_url = get_oauth_discovery_base_url(request)
return {
"authorization_servers": [f"{request_base_url}/mcp"],
"resource": f"{request_base_url}/mcp",
"resource": f"{resource_base_url}/mcp",
"scopes_supported": [],
}

Expand Down
117 changes: 113 additions & 4 deletions litellm/proxy/_experimental/mcp_server/oauth_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,19 +180,128 @@ def well_known_root_suffix() -> str:
return "" if root == "/" else root


# Env var: derive the OAuth-discovery base URL from the incoming request path
# (segments before ``/.well-known/``) in addition to ``PROXY_BASE_URL``. Off by
# default; opt-in per deployment so a reverse proxy that rewrites the path
# before LiteLLM sees the request cannot silently corrupt discovery values.
_OAUTH_DISCOVERY_PATH_FROM_REQUEST_ENV = "MCP_OAUTH_DISCOVERY_PATH_FROM_REQUEST"


def _oauth_discovery_path_from_request_enabled() -> bool:
raw = os.environ.get(_OAUTH_DISCOVERY_PATH_FROM_REQUEST_ENV, "").strip().lower()
return raw in ("1", "true", "yes", "on")


def _request_well_known_prefix(request: Request) -> str:
"""The URL path segments preceding ``/.well-known/`` in ``request.url.path``.

Empty when the discovery route is mounted at the root of the URL the client
called, or when ``/.well-known/`` is absent from the path (nothing to prefix).
"""
try:
path = request.url.path or ""
except AttributeError:
return ""
marker = "/.well-known/"
idx = path.find(marker)
if idx <= 0:
return ""
return path[:idx]


def get_oauth_discovery_base_url(request: Request) -> str:
"""Base URL used to construct the ``resource`` field in an MCP OAuth
protected-resource discovery document (RFC 9728 §3).

Defaults to :func:`get_request_base_url` (unchanged behaviour). When the
``MCP_OAUTH_DISCOVERY_PATH_FROM_REQUEST`` env var is truthy, the URL path
prefix the client used (the segments before ``/.well-known/``) is appended
to the resolved base — so ``resource`` matches the URL the client called
even when the same LiteLLM pod fronts multiple MCP origins mounted at
distinct URL path prefixes (RFC 9728 §3 requires exact match; a scalar
``PROXY_BASE_URL`` alone can express only one prefix).

Scope is deliberately narrow: only ``resource`` derives from the request
path. ``authorization_servers`` and every URL served by the authorization-
server metadata document (``authorization_endpoint``, ``token_endpoint``,
``registration_endpoint``, ``jwks_uri``) stay on the un-prefixed base
because those handlers are mounted only at root-relative paths — a
prefixed advertisement would 404 in the default deployment.

Opt-in because a reverse proxy that rewrites the request path before
LiteLLM receives it would produce an incorrect prefix; operators affirm
their topology preserves the client-visible prefix by setting the env var.
"""
base = get_request_base_url(request)
if not _oauth_discovery_path_from_request_enabled():
return base
prefix = _request_well_known_prefix(request)
if not prefix:
return base
return f"{base}{prefix}"


def get_passthrough_resource_metadata_url(scope: Scope, server_name: str) -> str:
"""The per-server protected-resource metadata URL matching the spelling the request
arrived on, so a strict RFC 9728 client resolves the same route the proxy registered.
``_original_path`` preserves the ``/{server}/mcp`` spelling through the
``dynamic_mcp_route`` rewrite; the ``SERVER_ROOT_PATH`` segment is inserted exactly as
the route decorators insert it (see :func:`well_known_root_suffix`)."""
the route decorators insert it (see :func:`well_known_root_suffix`).

When ``MCP_OAUTH_DISCOVERY_PATH_FROM_REQUEST`` is enabled (see
:func:`get_oauth_discovery_base_url`), the reverse-proxy-injected path prefix
the client used (segments before the ``/{server}/mcp`` or ``/mcp/{server}``
route) is included in the emitted URL. Otherwise an anonymous client
would follow the challenge to an un-prefixed well-known URL, and the
discovery builder — reading a request that has no prefix — would return
an un-prefixed ``resource`` that a strict RFC 9728 §3 client rejects
against the original prefixed MCP URL. Route-shape detection uses
``endswith`` because a request-prefixed path (``/tenant-a/{server}/mcp``)
is not covered by the original ``startswith`` check.
"""
request = Request(scope)
base_url = get_request_base_url(request)
_path = scope.get("_original_path") or scope.get("path", "") or ""

legacy_suffix = f"/{server_name}/mcp"
route_suffix = legacy_suffix if _path.endswith(legacy_suffix) else f"/mcp/{server_name}"

request_prefix = ""
if _oauth_discovery_path_from_request_enabled() and _path.endswith(route_suffix) and len(_path) > len(route_suffix):
request_prefix = _path[: -len(route_suffix)]

return f"{base_url}{request_prefix}/.well-known/oauth-protected-resource{well_known_root_suffix()}{route_suffix}"


def get_aggregate_resource_metadata_url(scope: Scope) -> str:
"""The aggregate ``/mcp`` protected-resource metadata URL matching the spelling
the request arrived on, for the gateway's aggregate-endpoint 401 challenge.

Mirrors :func:`get_passthrough_resource_metadata_url` for the aggregate route:
when ``MCP_OAUTH_DISCOVERY_PATH_FROM_REQUEST`` is enabled the reverse-proxy-
injected path prefix (segments before the trailing ``/mcp``) is included, so an
anonymous client on ``/tenant-a/mcp`` is challenged to
``/tenant-a/.well-known/oauth-protected-resource/mcp`` rather than the
unprefixed root; without the prefix the aggregate discovery builder would read
a request that has no prefix and return a resource that a strict RFC 9728 §3
client rejects against the original prefixed ``/mcp`` URL.
"""
request = Request(scope)
base_url = get_request_base_url(request)
_path = scope.get("_original_path") or scope.get("path", "") or ""

if _path.startswith(f"/{server_name}/mcp"):
return f"{base_url}/.well-known/oauth-protected-resource{well_known_root_suffix()}/{server_name}/mcp"
return f"{base_url}/.well-known/oauth-protected-resource{well_known_root_suffix()}/mcp/{server_name}"
aggregate_suffix = "/mcp"
request_prefix = ""
if (
_oauth_discovery_path_from_request_enabled()
and _path.endswith(aggregate_suffix)
and len(_path) > len(aggregate_suffix)
):
request_prefix = _path[: -len(aggregate_suffix)]

return (
f"{base_url}{request_prefix}/.well-known/oauth-protected-resource{well_known_root_suffix()}{aggregate_suffix}"
)


def get_passthrough_www_authenticate(
Expand Down
Loading
Loading