diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index 92f43e782d0..261ab104614 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -971,11 +971,16 @@ async def oauth_protected_resource_mcp( ) -async def _build_oauth_authorization_server_response( +def _build_oauth_authorization_server_response( request: Request, mcp_server_name: Optional[str], ) -> dict: - """Build OAuth authorization server metadata response (gateway-as-AS shape).""" + """Build OAuth authorization server metadata response (gateway-as-AS shape). + + Synchronous because the body only does dict construction and synchronous + registry lookups; unlike :func:`_build_oauth_protected_resource_response` + it does not need to await any upstream IO. + """ from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( global_mcp_server_manager, ) @@ -1039,7 +1044,7 @@ async def oauth_authorization_server_mcp_standard( Standard pattern: /mcp/{server_name} Discovery path: /.well-known/oauth-authorization-server/mcp/{server_name} """ - return await _build_oauth_authorization_server_response( + return _build_oauth_authorization_server_response( request=request, mcp_server_name=mcp_server_name, ) @@ -1058,7 +1063,7 @@ async def oauth_authorization_server_mcp( Supports both legacy pattern (/{server_name}) and root endpoint. """ - return await _build_oauth_authorization_server_response( + return _build_oauth_authorization_server_response( request=request, mcp_server_name=mcp_server_name, ) @@ -1129,7 +1134,7 @@ async def oauth_authorization_server_legacy(request: Request, mcp_server_name: s """ OAuth authorization server discovery for legacy /{server_name}/mcp pattern. """ - return await _build_oauth_authorization_server_response( + return _build_oauth_authorization_server_response( request=request, mcp_server_name=mcp_server_name, ) diff --git a/litellm/proxy/auth/handle_jwt.py b/litellm/proxy/auth/handle_jwt.py index 0e2941e3788..c6dd0281fa8 100644 --- a/litellm/proxy/auth/handle_jwt.py +++ b/litellm/proxy/auth/handle_jwt.py @@ -835,7 +835,7 @@ class JWTHandler: _unscoped_jwt_warning_emitted = False @classmethod - def _build_decode_kwargs(cls) -> dict: + def _build_decode_kwargs(cls, has_issuer_config: bool = False) -> dict: """Build the audience/issuer/options kwargs for ``jwt.decode``. Setting ``JWT_AUDIENCE`` (and optionally ``JWT_ISSUER``) turns on the @@ -843,6 +843,11 @@ class JWTHandler: minted by other applications that share the same IdP signing keys. When both are unset PyJWT only checks the signature and expiry, which is preserved for backward compatibility but logged once as a warning. + + ``has_issuer_config`` suppresses the warning when the caller has + configured per-issuer scoping via ``LiteLLM_JWTAuth.issuers``: this + global path is then just the fallback for tokens whose ``iss`` did not + match any configured issuer, not the only scoping mechanism. """ audience = os.getenv("JWT_AUDIENCE") issuer = os.getenv("JWT_ISSUER") @@ -850,6 +855,7 @@ class JWTHandler: if ( audience is None and issuer is None + and not has_issuer_config and not cls._unscoped_jwt_warning_emitted ): verbose_proxy_logger.warning( @@ -1051,7 +1057,9 @@ class JWTHandler: kid=kid, ) - decode_kwargs = self._build_decode_kwargs() + decode_kwargs = self._build_decode_kwargs( + has_issuer_config=bool(getattr(self.litellm_jwtauth, "issuers", None)) + ) public_key = await self.get_public_key(kid=kid) 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 bb77b9e9a9c..5fada0fb44b 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 @@ -1592,7 +1592,7 @@ async def test_oauth_authorization_server_returns_empty_scopes_when_none(): mock_request.headers = {} try: - response = await _build_oauth_authorization_server_response( + response = _build_oauth_authorization_server_response( request=mock_request, mcp_server_name="atlassian_mcp", ) @@ -1981,7 +1981,7 @@ async def test_discovery_root_includes_server_name_prefix(): try: # Call with mcp_server_name=None (root discovery) - response = await _build_oauth_authorization_server_response( + response = _build_oauth_authorization_server_response( request=mock_request, mcp_server_name=None, ) @@ -2024,7 +2024,7 @@ async def test_discovery_root_does_not_expose_private_server_for_external_client "litellm.proxy._experimental.mcp_server.discoverable_endpoints.IPAddressUtils.get_mcp_client_ip", return_value="198.51.100.10", ): - authorization_response = await _build_oauth_authorization_server_response( + authorization_response = _build_oauth_authorization_server_response( request=mock_request, mcp_server_name=None, ) diff --git a/tests/test_litellm/proxy/auth/test_handle_jwt.py b/tests/test_litellm/proxy/auth/test_handle_jwt.py index 90c5d4f4fc5..21cf7463e3c 100644 --- a/tests/test_litellm/proxy/auth/test_handle_jwt.py +++ b/tests/test_litellm/proxy/auth/test_handle_jwt.py @@ -2754,3 +2754,26 @@ def test_build_decode_kwargs_no_warning_when_scoped( if "neither JWT_AUDIENCE nor JWT_ISSUER" in r.getMessage() ] assert matching == [] + + +def test_build_decode_kwargs_no_warning_when_issuer_config_scoped( + monkeypatch, _reset_unscoped_warning_flag, caplog +): + """When per-issuer config (``LiteLLM_JWTAuth.issuers``) scopes the proxy, + the global path's unscoped-fallback warning must not fire — admins who + intentionally use issuer config without env-var scoping otherwise see a + misleading warning implying their JWT auth is unscoped.""" + import logging + + monkeypatch.delenv("JWT_AUDIENCE", raising=False) + monkeypatch.delenv("JWT_ISSUER", raising=False) + caplog.set_level(logging.WARNING) + + JWTHandler._build_decode_kwargs(has_issuer_config=True) + + matching = [ + r + for r in caplog.records + if "neither JWT_AUDIENCE nor JWT_ISSUER" in r.getMessage() + ] + assert matching == []