From 17863fa5cf73d4d51079afe518d966d16fb712ee Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:05:35 -0700 Subject: [PATCH 1/4] fix(mcp): match per-server OAuth metadata issuers --- .../mcp_server/discoverable_endpoints.py | 20 ++++++- .../mcp_server/test_discoverable_endpoints.py | 60 +++++++++++++++++-- 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index 42fbe82531c..580ed8eef1e 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -2490,7 +2490,11 @@ async def _build_oauth_protected_resource_response( return { "authorization_servers": [ - (f"{request_base_url}/{mcp_server_name}" if mcp_server_name else f"{request_base_url}") + resource_url + if explicitly_named + else f"{request_base_url}/{mcp_server_name}" + if mcp_server_name + else request_base_url ], "resource": resource_url, "scopes_supported": (mcp_server.scopes if mcp_server and mcp_server.scopes else []), @@ -2666,6 +2670,8 @@ async def oauth_protected_resource_mcp(request: Request, mcp_server_name: str | def _build_oauth_authorization_server_response( request: Request, mcp_server_name: str | None, + *, + issuer_path: str | None = None, ) -> dict: """Build OAuth authorization server metadata response (gateway-as-AS shape). @@ -2694,7 +2700,13 @@ def _build_oauth_authorization_server_response( _raise_unless_oauth2_discovery_server(mcp_server, mcp_server_name, "not an OAuth authorization server") - issuer: Final = f"{request_base_url}/{mcp_server_name}" if explicitly_named else request_base_url + issuer: Final = ( + f"{request_base_url}/{issuer_path}" + if issuer_path is not None + else f"{request_base_url}/{mcp_server_name}" + if explicitly_named + else request_base_url + ) return { "issuer": issuer, @@ -2724,6 +2736,7 @@ async def oauth_authorization_server_mcp_standard(request: Request, mcp_server_n return _build_oauth_authorization_server_response( request=request, mcp_server_name=mcp_server_name, + issuer_path=f"mcp/{mcp_server_name}", ) @@ -2802,7 +2815,7 @@ async def jwks_json(request: Request): # Additional legacy pattern support -@router.get("/.well-known/oauth-authorization-server/{mcp_server_name}/mcp") +@router.get(f"/.well-known/oauth-authorization-server{well_known_root_suffix()}/{{mcp_server_name}}/mcp") async def oauth_authorization_server_legacy(request: Request, mcp_server_name: str): """ OAuth authorization server discovery for legacy /{server_name}/mcp pattern. @@ -2810,6 +2823,7 @@ async def oauth_authorization_server_legacy(request: Request, mcp_server_name: s return _build_oauth_authorization_server_response( request=request, mcp_server_name=mcp_server_name, + issuer_path=f"{mcp_server_name}/mcp", ) 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 1b7e3d6a1c3..cd861b76bb8 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 @@ -3419,16 +3419,16 @@ async def test_oauth_protected_resource_gateway_managed_oauth2_advertises_gatewa relay_response = await _build_oauth_protected_resource_response( request=mock_request, mcp_server_name="relay_mcp", use_standard_pattern=True ) - assert relay_response["authorization_servers"] == ["https://litellm.example.com/relay_mcp"] + assert relay_response["authorization_servers"] == ["https://litellm.example.com/mcp/relay_mcp"] relay_legacy_response = await _build_oauth_protected_resource_response( request=mock_request, mcp_server_name="relay_mcp", use_standard_pattern=False ) - assert relay_legacy_response["authorization_servers"] == ["https://litellm.example.com/relay_mcp"] + assert relay_legacy_response["authorization_servers"] == ["https://litellm.example.com/relay_mcp/mcp"] delegated_response = await _build_oauth_protected_resource_response( request=mock_request, mcp_server_name="delegated_mcp", use_standard_pattern=True ) - assert delegated_response["authorization_servers"] == ["https://litellm.example.com/delegated_mcp"] + assert delegated_response["authorization_servers"] == ["https://litellm.example.com/mcp/delegated_mcp"] finally: global_mcp_server_manager.registry.clear() @@ -9120,8 +9120,8 @@ async def test_named_discovery_issuer_matches_protected_resource_authorization_s from fastapi import Request from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( - _build_oauth_authorization_server_response, _build_oauth_protected_resource_response, + oauth_authorization_server_mcp_standard, ) from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( global_mcp_server_manager, @@ -9139,10 +9139,10 @@ async def test_named_discovery_issuer_matches_protected_resource_authorization_s resource_response = await _build_oauth_protected_resource_response( request=mock_request, mcp_server_name="test_oauth", use_standard_pattern=True ) - authorization_response = _build_oauth_authorization_server_response( + authorization_response = await oauth_authorization_server_mcp_standard( request=mock_request, mcp_server_name="test_oauth" ) - assert resource_response["authorization_servers"] == ["https://llm.example.com/test_oauth"] + assert resource_response["authorization_servers"] == ["https://llm.example.com/mcp/test_oauth"] assert authorization_response["issuer"] == resource_response["authorization_servers"][0] finally: global_mcp_server_manager.registry.clear() @@ -11186,3 +11186,51 @@ async def test_dcr_refusal_is_actionable_without_upstream_body( assert f"HTTP {upstream_status}" in str(exc.value.detail) assert "pre-registered OAuth client" in str(exc.value.detail) assert "private upstream details" not in str(exc.value.detail) + + +@pytest.mark.parametrize("prefix", ["", "/tenant-a", "/tenant-b"]) +@pytest.mark.parametrize( + ("server_name", "pattern"), + [ + ("issuer_test", "mcp/{server}"), + ("issuer_test", "{server}/mcp"), + ("issuer_test", "{server}"), + ("mcp", "mcp/{server}"), + ("mcp", "{server}/mcp"), + ], +) +def test_per_server_authorization_metadata_issuer_matches_discovery_path( + _no_proxy_base_url, _isolated_mcp_registry, prefix, server_name, pattern +): + server = _create_oauth2_server(server_id=server_name, name=server_name, server_name=server_name, alias=server_name) + _isolated_mcp_registry[server.server_id] = server + client = _prefixed_discovery_client(["/tenant-a", "/tenant-b"]) + path = pattern.format(server=server_name) + response = client.get(f"{prefix}/.well-known/oauth-authorization-server/{path}") + assert response.status_code == 200 + metadata = response.json() + assert metadata["issuer"] == f"http://testserver{prefix}/{path}" + assert metadata["authorization_endpoint"] == f"http://testserver{prefix}/{server_name}/authorize" + assert metadata["token_endpoint"] == f"http://testserver{prefix}/{server_name}/token" + assert metadata["registration_endpoint"] == f"http://testserver{prefix}/{server_name}/register" + + +@pytest.mark.parametrize("prefix", ["", "/tenant-a"]) +@pytest.mark.parametrize("relay", [False, True]) +@pytest.mark.parametrize("pattern", ["mcp/{server}", "{server}/mcp"]) +def test_named_resource_discovery_follows_matching_authorization_issuer( + _no_proxy_base_url, _isolated_mcp_registry, prefix, relay, pattern +): + server = _create_oauth2_server().model_copy(update={"per_server_oauth_discovery": relay}) + _isolated_mcp_registry[server.server_id] = server + client = _prefixed_discovery_client(["/tenant-a"]) + path = pattern.format(server=server.server_name) + response = client.get(f"{prefix}/.well-known/oauth-protected-resource/{path}") + assert response.status_code == 200 + resource = response.json() + issuer_path = path if relay else "mcp" + assert resource["resource"] == f"http://testserver{prefix}/{path}" + assert resource["authorization_servers"] == [f"http://testserver{prefix}/{issuer_path}"] + authorization = client.get(f"{prefix}/.well-known/oauth-authorization-server/{issuer_path}") + assert authorization.status_code == 200 + assert authorization.json()["issuer"] == resource["authorization_servers"][0] From da61fa35642c03e68b1a2b08a58845d353fa1bb1 Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:11:42 -0700 Subject: [PATCH 2/4] fix(mcp): preserve existing relay discovery identifiers --- .../mcp_server/discoverable_endpoints.py | 6 +----- .../mcp_server/test_discoverable_endpoints.py | 14 +++++++------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index 580ed8eef1e..57dbfafc61a 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -2490,11 +2490,7 @@ async def _build_oauth_protected_resource_response( return { "authorization_servers": [ - resource_url - if explicitly_named - else f"{request_base_url}/{mcp_server_name}" - if mcp_server_name - else request_base_url + (f"{request_base_url}/{mcp_server_name}" if mcp_server_name else f"{request_base_url}") ], "resource": resource_url, "scopes_supported": (mcp_server.scopes if mcp_server and mcp_server.scopes else []), 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 cd861b76bb8..7ecc1313ba3 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 @@ -3419,16 +3419,16 @@ async def test_oauth_protected_resource_gateway_managed_oauth2_advertises_gatewa relay_response = await _build_oauth_protected_resource_response( request=mock_request, mcp_server_name="relay_mcp", use_standard_pattern=True ) - assert relay_response["authorization_servers"] == ["https://litellm.example.com/mcp/relay_mcp"] + assert relay_response["authorization_servers"] == ["https://litellm.example.com/relay_mcp"] relay_legacy_response = await _build_oauth_protected_resource_response( request=mock_request, mcp_server_name="relay_mcp", use_standard_pattern=False ) - assert relay_legacy_response["authorization_servers"] == ["https://litellm.example.com/relay_mcp/mcp"] + assert relay_legacy_response["authorization_servers"] == ["https://litellm.example.com/relay_mcp"] delegated_response = await _build_oauth_protected_resource_response( request=mock_request, mcp_server_name="delegated_mcp", use_standard_pattern=True ) - assert delegated_response["authorization_servers"] == ["https://litellm.example.com/mcp/delegated_mcp"] + assert delegated_response["authorization_servers"] == ["https://litellm.example.com/delegated_mcp"] finally: global_mcp_server_manager.registry.clear() @@ -9120,8 +9120,8 @@ async def test_named_discovery_issuer_matches_protected_resource_authorization_s from fastapi import Request from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + _build_oauth_authorization_server_response, _build_oauth_protected_resource_response, - oauth_authorization_server_mcp_standard, ) from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( global_mcp_server_manager, @@ -9139,10 +9139,10 @@ async def test_named_discovery_issuer_matches_protected_resource_authorization_s resource_response = await _build_oauth_protected_resource_response( request=mock_request, mcp_server_name="test_oauth", use_standard_pattern=True ) - authorization_response = await oauth_authorization_server_mcp_standard( + authorization_response = _build_oauth_authorization_server_response( request=mock_request, mcp_server_name="test_oauth" ) - assert resource_response["authorization_servers"] == ["https://llm.example.com/mcp/test_oauth"] + assert resource_response["authorization_servers"] == ["https://llm.example.com/test_oauth"] assert authorization_response["issuer"] == resource_response["authorization_servers"][0] finally: global_mcp_server_manager.registry.clear() @@ -11228,7 +11228,7 @@ def test_named_resource_discovery_follows_matching_authorization_issuer( response = client.get(f"{prefix}/.well-known/oauth-protected-resource/{path}") assert response.status_code == 200 resource = response.json() - issuer_path = path if relay else "mcp" + issuer_path = server.server_name if relay else "mcp" assert resource["resource"] == f"http://testserver{prefix}/{path}" assert resource["authorization_servers"] == [f"http://testserver{prefix}/{issuer_path}"] authorization = client.get(f"{prefix}/.well-known/oauth-authorization-server/{issuer_path}") From f66239a50f7a2e6f0eb24ce49ea98329bf19c53b Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:13:47 -0700 Subject: [PATCH 3/4] test(mcp): cover static-prefix issuer discovery --- .../mcp_server/test_discoverable_endpoints.py | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) 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 7ecc1313ba3..e08d00f07e1 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 @@ -11234,3 +11234,50 @@ def test_named_resource_discovery_follows_matching_authorization_issuer( authorization = client.get(f"{prefix}/.well-known/oauth-authorization-server/{issuer_path}") assert authorization.status_code == 200 assert authorization.json()["issuer"] == resource["authorization_servers"][0] + + +def test_static_root_path_authorization_discovery_preserves_issuer(monkeypatch): + import subprocess + import sys + + monkeypatch.setenv("SERVER_ROOT_PATH", "/gateway") + monkeypatch.setenv("PROXY_BASE_URL", "http://testserver/gateway") + result = subprocess.run( + [ + sys.executable, + "-c", + """ +import json +from fastapi import FastAPI +from fastapi.testclient import TestClient +from litellm.proxy._experimental.mcp_server.discoverable_endpoints import router +from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager +from litellm.types.mcp import MCPAuth, MCPTransport +from litellm.types.mcp_server.mcp_server_manager import MCPServer + +global_mcp_server_manager.registry['example'] = MCPServer( + server_id='example', name='example', server_name='example', alias='example', + transport=MCPTransport.http, auth_type=MCPAuth.oauth2, + authorization_url='https://idp.example.com/authorize', token_url='https://idp.example.com/token', +) +app = FastAPI(root_path='/gateway') +app.include_router(router) +with TestClient(app) as client: + responses = { + path: client.get('/.well-known/oauth-authorization-server/gateway/' + path) + for path in ('mcp/example', 'example/mcp', 'example', 'mcp') + } + print(json.dumps({path: {'status': response.status_code, 'body': response.json()} + for path, response in responses.items()})) +""", + ], + capture_output=True, + text=True, + check=True, + timeout=60, + ) + responses = json.loads(result.stdout) + for path in ("mcp/example", "example/mcp", "example", "mcp"): + assert responses[path]["status"] == 200, responses[path] + assert responses[path]["body"]["issuer"] == f"http://testserver/gateway/{path}" + assert responses["example/mcp"]["body"]["token_endpoint"] == "http://testserver/gateway/example/token" From 0690520080fde5f63878ac1b4f0e00cc19843f36 Mon Sep 17 00:00:00 2001 From: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:26:35 -0700 Subject: [PATCH 4/4] test(mcp): isolate static-prefix discovery UI assets --- .../_experimental/mcp_server/test_discoverable_endpoints.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 e08d00f07e1..e49134dd0f3 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 @@ -11236,12 +11236,13 @@ def test_named_resource_discovery_follows_matching_authorization_issuer( assert authorization.json()["issuer"] == resource["authorization_servers"][0] -def test_static_root_path_authorization_discovery_preserves_issuer(monkeypatch): +def test_static_root_path_authorization_discovery_preserves_issuer(monkeypatch, tmp_path): import subprocess import sys monkeypatch.setenv("SERVER_ROOT_PATH", "/gateway") monkeypatch.setenv("PROXY_BASE_URL", "http://testserver/gateway") + monkeypatch.setenv("LITELLM_UI_PATH", str(tmp_path / "ui")) result = subprocess.run( [ sys.executable,