From 67a8c0f27b61f8cdcc2e38c968503102803ff0a0 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:27:50 +0000 Subject: [PATCH] build MCP OAuth discovery metadata from mcp.shared.auth models Replace hand-built dicts in the well-known oauth-authorization-server and oauth-protected-resource endpoints (gateway and BYOK flavors) with the SDK's RFC 8414 OAuthMetadata and RFC 9728 ProtectedResourceMetadata pydantic models, so field names and URL shapes are validated against the spec instead of being free-form. Bare-origin URLs now serialize in RFC 3986 normalized form (trailing slash), matching what SDK-based MCP servers emit. --- .../mcp_server/byok_oauth_endpoints.py | 28 ++++++++------ .../mcp_server/discoverable_endpoints.py | 38 ++++++++++--------- .../mcp_server/test_discoverable_endpoints.py | 4 +- 3 files changed, 40 insertions(+), 30 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/byok_oauth_endpoints.py b/litellm/proxy/_experimental/mcp_server/byok_oauth_endpoints.py index 2f5973ca371..f5b8349ac40 100644 --- a/litellm/proxy/_experimental/mcp_server/byok_oauth_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/byok_oauth_endpoints.py @@ -600,28 +600,32 @@ def _build_authorize_html( @router.get("/.well-known/oauth-authorization-server", include_in_schema=False) async def oauth_authorization_server_metadata(request: Request) -> JSONResponse: """RFC 8414 Authorization Server Metadata for the BYOK OAuth flow.""" + from mcp.shared.auth import OAuthMetadata + base_url = get_request_base_url(request) return JSONResponse( - { - "issuer": base_url, - "authorization_endpoint": f"{base_url}/v1/mcp/oauth/authorize", - "token_endpoint": f"{base_url}/v1/mcp/oauth/token", - "response_types_supported": ["code"], - "grant_types_supported": ["authorization_code"], - "code_challenge_methods_supported": ["S256"], - } + OAuthMetadata( + issuer=base_url, # type: ignore[arg-type] + authorization_endpoint=f"{base_url}/v1/mcp/oauth/authorize", # type: ignore[arg-type] + token_endpoint=f"{base_url}/v1/mcp/oauth/token", # type: ignore[arg-type] + response_types_supported=["code"], + grant_types_supported=["authorization_code"], + code_challenge_methods_supported=["S256"], + ).model_dump(mode="json", exclude_none=True) ) @router.get("/.well-known/oauth-protected-resource", include_in_schema=False) async def oauth_protected_resource_metadata(request: Request) -> JSONResponse: """RFC 9728 Protected Resource Metadata pointing back at this server.""" + from mcp.shared.auth import ProtectedResourceMetadata + base_url = get_request_base_url(request) return JSONResponse( - { - "resource": base_url, - "authorization_servers": [base_url], - } + ProtectedResourceMetadata( + resource=base_url, # type: ignore[arg-type] + authorization_servers=[base_url], # type: ignore[list-item] + ).model_dump(mode="json", exclude_none=True) ) diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index 3beddd2c435..1446edddf00 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -1043,19 +1043,21 @@ async def _build_oauth_protected_resource_response( ), ) - return { - "authorization_servers": [ + from mcp.shared.auth import ProtectedResourceMetadata + + return ProtectedResourceMetadata( + resource=resource_url, # type: ignore[arg-type] + authorization_servers=[ ( f"{request_base_url}/{mcp_server_name}" if mcp_server_name else f"{request_base_url}" ) - ], - "resource": resource_url, - "scopes_supported": ( + ], # type: ignore[list-item] + scopes_supported=( mcp_server.scopes if mcp_server and mcp_server.scopes else [] ), - } + ).model_dump(mode="json", exclude_none=True) # Standard MCP pattern: /.well-known/oauth-protected-resource/mcp/{server_name} @@ -1145,24 +1147,26 @@ def _build_oauth_authorization_server_response( mcp_server_name, client_ip=client_ip ) - return { - "issuer": request_base_url, # point to your proxy - "authorization_endpoint": authorization_endpoint, - "token_endpoint": token_endpoint, - "response_types_supported": ["code"], - "scopes_supported": ( + from mcp.shared.auth import OAuthMetadata + + return OAuthMetadata( + issuer=request_base_url, # type: ignore[arg-type] + authorization_endpoint=authorization_endpoint, # type: ignore[arg-type] + token_endpoint=token_endpoint, # type: ignore[arg-type] + response_types_supported=["code"], + scopes_supported=( mcp_server.scopes if mcp_server and mcp_server.scopes else [] ), - "grant_types_supported": ["authorization_code", "refresh_token"], - "code_challenge_methods_supported": ["S256"], - "token_endpoint_auth_methods_supported": ["client_secret_post"], + grant_types_supported=["authorization_code", "refresh_token"], + code_challenge_methods_supported=["S256"], + token_endpoint_auth_methods_supported=["client_secret_post"], # Claude expects a registration endpoint, even if we just fake it - "registration_endpoint": ( + registration_endpoint=( # type: ignore[arg-type] f"{request_base_url}/{mcp_server_name}/register" if mcp_server_name else f"{request_base_url}/register" ), - } + ).model_dump(mode="json", exclude_none=True) # Standard MCP pattern: /.well-known/oauth-authorization-server/mcp/{server_name} diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index 6fd935e3364..a5802982d5e 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -2015,7 +2015,9 @@ async def test_discovery_root_does_not_expose_private_server_for_external_client assert "/test_oauth/" not in authorization_response["authorization_endpoint"] assert "/test_oauth/" not in authorization_response["token_endpoint"] assert authorization_response["scopes_supported"] == [] - assert resource_response["authorization_servers"] == ["https://llm.example.com"] + assert resource_response["authorization_servers"] == [ + "https://llm.example.com/" + ] assert resource_response["scopes_supported"] == [] finally: global_mcp_server_manager.registry.clear()