diff --git a/litellm/proxy/_experimental/mcp_server/exceptions.py b/litellm/proxy/_experimental/mcp_server/exceptions.py index 9d0f21d37d2..c70d71fc147 100644 --- a/litellm/proxy/_experimental/mcp_server/exceptions.py +++ b/litellm/proxy/_experimental/mcp_server/exceptions.py @@ -28,7 +28,7 @@ class MCPUpstreamAuthError(Exception): self.server_name = server_name super().__init__(f"Upstream MCP server {server_name!r} returned {status_code}") - def to_http_exception(self) -> HTTPException: + def to_http_exception(self, base_url: Optional[str] = None) -> HTTPException: """Convert this upstream-auth error into an ``HTTPException`` that preserves the upstream status code and any ``WWW-Authenticate`` challenge, so standards-compliant MCP clients can trigger the @@ -38,13 +38,17 @@ class MCPUpstreamAuthError(Exception): RFC 7235 §3.1) we fabricate a ``Bearer resource_metadata=`` challenge that points at the gateway's standard-pattern well-known endpoint for this server, so MCP clients can still initiate RFC 9728 discovery - against the upstream IdP via the gateway's proxied metadata. + against the upstream IdP via the gateway's proxied metadata. Callers + should pass ``base_url`` (the gateway origin, no trailing slash) so + the fabricated URI is absolute as RFC 9728 §3.2 requires; strict + clients reject relative URIs in the Bearer challenge. """ challenge: Optional[str] = self.www_authenticate if challenge is None and self.status_code == 401: + prefix = base_url.rstrip("/") if base_url else "" challenge = ( "Bearer resource_metadata=" - f'"/.well-known/oauth-protected-resource/mcp/{self.server_name}"' + f'"{prefix}/.well-known/oauth-protected-resource/mcp/{self.server_name}"' ) return HTTPException( status_code=self.status_code, diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py index 32a1ca4f290..4d6543c72b1 100644 --- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py @@ -47,6 +47,9 @@ if MCP_AVAILABLE: from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( global_mcp_server_manager, ) + from litellm.proxy._experimental.mcp_server.oauth_utils import ( + get_request_base_url, + ) from litellm.proxy._experimental.mcp_server.server import ( ListMCPToolsRestAPIResponseObject, MCPServer, @@ -437,6 +440,7 @@ if MCP_AVAILABLE: mcp_auth_header: Optional[str], raw_headers_from_request: dict, user_api_key_dict: "UserAPIKeyAuth", + request: Request, ) -> dict: """ Resolve and fetch tools for a single specified MCP server. @@ -511,7 +515,7 @@ if MCP_AVAILABLE: except MCPUpstreamAuthError as e: # Pass-through server returned 401 — surface it to the client so # standards-compliant MCP clients trigger the upstream OAuth flow. - raise e.to_http_exception() + raise e.to_http_exception(base_url=get_request_base_url(request)) except Exception as e: verbose_logger.exception(f"Error getting tools from {server.name}: {e}") return { @@ -536,6 +540,7 @@ if MCP_AVAILABLE: mcp_auth_header: Optional[str], raw_headers_from_request: dict, user_api_key_dict: UserAPIKeyAuth, + request: Request, ) -> dict: """Handle tool listing for a single server_id request.""" # Resolve a server name to its UUID if needed @@ -604,7 +609,7 @@ if MCP_AVAILABLE: except MCPUpstreamAuthError as e: # Pass-through server returned 401 — surface it to the client so # standards-compliant MCP clients trigger the upstream OAuth flow. - raise e.to_http_exception() + raise e.to_http_exception(base_url=get_request_base_url(request)) except Exception as e: verbose_logger.exception(f"Error getting tools from {server.name}: {e}") return { @@ -692,6 +697,7 @@ if MCP_AVAILABLE: mcp_auth_header=mcp_auth_header, raw_headers_from_request=raw_headers_from_request, user_api_key_dict=user_api_key_dict, + request=request, ) else: if not allowed_server_ids: diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 9a92e7fe080..104aff5856a 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -3231,7 +3231,9 @@ if MCP_AVAILABLE: except MCPUpstreamAuthError as e: # Pass-through server returned 401 — surface it to the client so # standards-compliant MCP clients trigger the upstream OAuth flow. - raise e.to_http_exception() + raise e.to_http_exception( + base_url=get_request_base_url(StarletteRequest(scope)) + ) except HTTPException: # Re-raise HTTP exceptions to preserve status codes and details raise @@ -3334,7 +3336,9 @@ if MCP_AVAILABLE: except MCPUpstreamAuthError as e: # Pass-through server returned 401 — surface it to the client so # standards-compliant MCP clients trigger the upstream OAuth flow. - raise e.to_http_exception() + raise e.to_http_exception( + base_url=get_request_base_url(StarletteRequest(scope)) + ) except HTTPException: # Re-raise HTTP exceptions to preserve status codes and details # (e.g. 401 + WWW-Authenticate challenges from OAuth pass-through). diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_tools.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_tools.py index 2d7ba622288..aa36cf88155 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_tools.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_tools.py @@ -140,6 +140,20 @@ def test_to_http_exception_fabricates_resource_metadata_when_upstream_omits_head } +def test_to_http_exception_fabricates_absolute_resource_metadata_with_base_url(): + err = MCPUpstreamAuthError( + status_code=401, + www_authenticate=None, + server_name="sample_docs", + ) + + http_exc = err.to_http_exception(base_url="https://gateway.example.com/") + assert http_exc.status_code == 401 + assert http_exc.headers == { + "www-authenticate": 'Bearer resource_metadata="https://gateway.example.com/.well-known/oauth-protected-resource/mcp/sample_docs"' + } + + def test_to_http_exception_skips_challenge_for_non_401_status(): err = MCPUpstreamAuthError( status_code=403,