diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 08151d8cab2..2ef6253ef50 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -2500,9 +2500,8 @@ class MCPServerManager: # Filter blank scopes (e.g. YAML ``scopes: [""]``) the same way the DB-build path does, so # an all-blank list normalizes to None rather than a ``("",)`` tuple that skips the # entra_obo fail-closed scope precondition and POSTs an empty scope to the IdP. - resolved_scopes = self._extract_scopes(server_config.get("scopes")) or ( - gated_oauth_metadata.scopes if gated_oauth_metadata else None - ) + configured_scopes = self._extract_scopes(server_config.get("scopes")) + resolved_scopes = configured_scopes or (gated_oauth_metadata.scopes if gated_oauth_metadata else None) resolved_authorization_url = manual_authorization_url or ( gated_oauth_metadata.authorization_url if gated_oauth_metadata else None ) @@ -2579,6 +2578,7 @@ class MCPServerManager: client_secret=server_config.get("client_secret", None), oauth2_flow=self._explicit_oauth2_flow(config_oauth2_flow), scopes=resolved_scopes, + configured_scopes=tuple(configured_scopes) if configured_scopes else None, issuer=effective_issuer, issuer_is_anchored=use_issuer_anchor, authorization_url=resolved_authorization_url, @@ -3041,6 +3041,18 @@ class MCPServerManager: if scopes_value is not None: scopes = self._extract_scopes(scopes_value) + stored_scopes: Final[object] = credentials_dict.get("scopes") if credentials_dict else None + scopes_as_objects: Final = ( + cast(Sequence[object], stored_scopes) # cast-ok: list shape validated below + if isinstance(stored_scopes, list) + else () + ) + configured_scopes: Final = ( + tuple(scope for scope in scopes_as_objects if isinstance(scope, str)) + if scopes_as_objects and all(isinstance(scope, str) and scope for scope in scopes_as_objects) + else None + ) + name_for_prefix: Final = mcp_server.alias or mcp_server.server_name or mcp_server.server_id mcp_info: Final[MCPInfo] = _mcp_info.copy() @@ -3115,6 +3127,7 @@ class MCPServerManager: client_secret=client_secret_value or getattr(mcp_server, "client_secret", None), oauth2_flow=self._explicit_oauth2_flow(getattr(mcp_server, "oauth2_flow", None)), scopes=resolved_scopes, + configured_scopes=configured_scopes, issuer=effective_issuer, issuer_is_anchored=use_issuer_anchor, authorization_url=manual_authorization_url or getattr(gated_oauth_metadata, "authorization_url", None), @@ -7088,7 +7101,11 @@ class MCPServerManager: spec_path=server.spec_path, transport=server.transport, auth_type=server.auth_type, - credentials={"scopes": server.scopes} if server.scopes else None, + credentials=( + {"scopes": list(server.configured_scopes)} # mutable-ok: MCPCredentials requires a JSON-array list + if server.configured_scopes + else None + ), created_at=server.created_at, updated_at=server.updated_at, teams=[], diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index 985d31af997..cb32299b143 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -99,6 +99,7 @@ class MCPServer(BaseModel): configured_authorization_url: str | None = None configured_token_url: str | None = None configured_registration_url: str | None = None + configured_scopes: tuple[str, ...] | None = None # How the gateway authenticates to the upstream token endpoint. When # "client_secret_basic" the credentials go in an HTTP Basic Authorization # header (omitted from the body); None defaults to "client_secret_post". diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index c0aa6c9cd32..6b51cc342a5 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -61,6 +61,8 @@ from litellm.types.llms.custom_http import httpxSpecialProvider from litellm.types.mcp import MCPAuth, MCPAuthType from litellm.types.mcp_server.mcp_server_manager import MCPOAuthMetadata, MCPServer from litellm.caching.caching import DualCache +from litellm.caching.llm_caching_handler import LLMClientCache +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler import litellm from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.proxy.utils import ProxyLogging @@ -10427,6 +10429,7 @@ def test_build_mcp_server_table_carries_oauth2_flow(): client_id="client-123", client_secret="secret-xyz", scopes=["scope:a", "scope:b"], + configured_scopes=("scope:a", "scope:b"), ) table = manager._build_mcp_server_table(server) @@ -10456,6 +10459,206 @@ def test_build_mcp_server_table_carries_null_oauth2_flow(): assert table.oauth2_flow is None +async def _mock_oauth_discovery( + respx_mock: MockRouter, + monkeypatch: pytest.MonkeyPatch, + *, + server_url: str, + scopes: list[str], +) -> None: + resource_metadata_url: Final[str] = "https://up.example.com/.well-known/oauth-protected-resource" + authorization_server_url: Final[str] = "https://up.example.com" + authorization_metadata_url: Final[str] = f"{authorization_server_url}/.well-known/oauth-authorization-server" + respx_mock.get(server_url).respond( + status_code=401, + headers={"WWW-Authenticate": f'Bearer resource_metadata="{resource_metadata_url}"'}, + ) + respx_mock.get(resource_metadata_url).respond( + json={"authorization_servers": [authorization_server_url], "scopes_supported": scopes} + ) + respx_mock.get(authorization_metadata_url).respond( + json={ + "issuer": authorization_server_url, + "authorization_endpoint": f"{authorization_server_url}/authorize", + "token_endpoint": f"{authorization_server_url}/token", + } + ) + clients: Final[LLMClientCache] = LLMClientCache() + monkeypatch.setattr(litellm, "in_memory_llm_clients_cache", clients) + http_handler: Final[AsyncHTTPHandler] = AsyncHTTPHandler() + await http_handler.client.aclose() + http_handler.client = httpx.AsyncClient(transport=httpx.MockTransport(respx_mock.async_handler)) + http_handler._owns_client = True + cache_key: Final[str] = f"async_httpx_clienttimeout_{MCP_METADATA_TIMEOUT}{httpxSpecialProvider.MCP.value}" + clients.set_cache(cache_key, http_handler) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("discovery_on_startup", [True, False]) +async def test_management_view_serves_configured_scopes_not_discovered_ones_from_db( + discovery_on_startup: bool, + respx_mock: MockRouter, + monkeypatch: pytest.MonkeyPatch, +) -> None: + row: Final[LiteLLM_MCPServerTable] = LiteLLM_MCPServerTable( + server_id="discovered-scopes-db", + alias="discovered_scopes_db", + url="https://up.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + oauth2_flow="authorization_code", + created_at=datetime.now(), + updated_at=datetime.now(), + ) + await _mock_oauth_discovery(respx_mock, monkeypatch, server_url=row.url or "", scopes=["discovered.read"]) + env: Final[dict[str, str]] = {"LITELLM_MCP_OAUTH_DISCOVERY_ON_STARTUP": "1"} if discovery_on_startup else {} + with patch.dict(os.environ, env, clear=True): + manager: Final[MCPServerManager] = MCPServerManager() + built: Final[MCPServer] = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False) + manager.registry[built.server_id] = built + resolved: Final[MCPServer] = await manager.ensure_oauth_metadata_discovered(built) + + assert resolved.scopes == ["discovered.read"] + view: Final[LiteLLM_MCPServerTable] = manager._build_mcp_server_table(resolved) + assert view.credentials is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("stored_scopes", "runtime_scopes"), + [ + (None, ["openid"]), + ([], ["openid"]), + ([""], ["openid"]), + (["read", ""], ["read"]), + (["read", 7], ["read"]), + ("read", ["read"]), + ], +) +async def test_management_view_omits_invalid_or_absent_db_scopes( + stored_scopes: list[str | int] | str | None, + runtime_scopes: list[str], + respx_mock: MockRouter, + monkeypatch: pytest.MonkeyPatch, +) -> None: + row: Final[LiteLLM_MCPServerTable] = LiteLLM_MCPServerTable.model_construct( + server_id="empty-scopes-db", + alias="empty_scopes_db", + url="https://up.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + oauth2_flow="authorization_code", + credentials=json.dumps({"scopes": stored_scopes}), + created_at=datetime.now(), + updated_at=datetime.now(), + ) + await _mock_oauth_discovery(respx_mock, monkeypatch, server_url=row.url or "", scopes=["openid"]) + env: Final[dict[str, str]] = {"LITELLM_MCP_OAUTH_DISCOVERY_ON_STARTUP": "1"} + with patch.dict(os.environ, env, clear=True): + manager: Final[MCPServerManager] = MCPServerManager() + built: Final[MCPServer] = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False) + + assert built.scopes == runtime_scopes + view: Final[LiteLLM_MCPServerTable] = manager._build_mcp_server_table(built) + assert view.credentials is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("stored_scopes", "runtime_scopes"), + [ + (["calendar.read"], ["calendar.read"]), + ([" "], ["discovered.read"]), + (["read", " "], ["read"]), + (["read", "read"], ["read", "read"]), + ], +) +async def test_management_view_serves_explicitly_configured_scopes_from_db( + stored_scopes: list[str], + runtime_scopes: list[str], + respx_mock: MockRouter, + monkeypatch: pytest.MonkeyPatch, +) -> None: + row: Final[LiteLLM_MCPServerTable] = LiteLLM_MCPServerTable( + server_id="configured-scopes-db", + alias="configured_scopes_db", + url="https://up.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + oauth2_flow="authorization_code", + credentials={"scopes": stored_scopes}, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + await _mock_oauth_discovery(respx_mock, monkeypatch, server_url=row.url or "", scopes=["discovered.read"]) + env: Final[dict[str, str]] = {"LITELLM_MCP_OAUTH_DISCOVERY_ON_STARTUP": "1"} + with patch.dict(os.environ, env, clear=True): + manager: Final[MCPServerManager] = MCPServerManager() + built: Final[MCPServer] = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False) + + assert built.scopes == runtime_scopes + view: Final[LiteLLM_MCPServerTable] = manager._build_mcp_server_table(built) + assert view.credentials == {"scopes": stored_scopes} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("configured_scopes", [None, ["calendar.read"]]) +async def test_management_view_scopes_follow_yaml_config_not_discovery( + configured_scopes: list[str] | None, + respx_mock: MockRouter, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config: Final[dict[str, dict[str, object]]] = { + "yamlscopes": { + "url": "https://up.example.com/mcp", + "transport": MCPTransport.http, + "auth_type": MCPAuth.oauth2, + "oauth2_flow": "authorization_code", + "client_id": "cid", + "client_secret": "csec", + **({"scopes": configured_scopes} if configured_scopes else {}), + } + } + await _mock_oauth_discovery(respx_mock, monkeypatch, server_url="https://up.example.com/mcp", scopes=["discovered.read"]) + env: Final[dict[str, str]] = {"LITELLM_MCP_OAUTH_DISCOVERY_ON_STARTUP": "1"} + with patch.dict(os.environ, env, clear=True): + manager: Final[MCPServerManager] = MCPServerManager() + await manager.load_servers_from_config(config) + + server: Final[MCPServer] = next(iter(manager.config_mcp_servers.values())) + expected_runtime: Final[list[str]] = configured_scopes or ["discovered.read"] + assert server.scopes == expected_runtime + view: Final[LiteLLM_MCPServerTable] = manager._build_mcp_server_table(server) + assert view.credentials == ({"scopes": configured_scopes} if configured_scopes else None) + + +@pytest.mark.asyncio +async def test_lazy_yaml_discovery_keeps_configured_scopes_out_of_the_management_view( + respx_mock: MockRouter, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config: Final[dict[str, dict[str, object]]] = { + "lazyyamlscopes": { + "url": "https://up.example.com/mcp", + "transport": MCPTransport.http, + "auth_type": MCPAuth.oauth2, + "oauth2_flow": "authorization_code", + "client_id": "cid", + "client_secret": "csec", + } + } + await _mock_oauth_discovery(respx_mock, monkeypatch, server_url="https://up.example.com/mcp", scopes=["discovered.read"]) + with patch.dict(os.environ, {}, clear=True): + manager: Final[MCPServerManager] = MCPServerManager() + await manager.load_servers_from_config(config) + server: Final[MCPServer] = next(iter(manager.config_mcp_servers.values())) + resolved: Final[MCPServer] = await manager.ensure_oauth_metadata_discovered(server) + + assert resolved.scopes == ["discovered.read"] + view: Final[LiteLLM_MCPServerTable] = manager._build_mcp_server_table(resolved) + assert view.credentials is None + + @pytest.mark.asyncio async def test_resolve_toolset_tool_permissions_single_db_fetch_across_checks(): """The server-level and tool-level permission primitives each resolve the diff --git a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py index f3fc45480e1..3d2487ec6ca 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py @@ -4582,13 +4582,9 @@ class TestMCPApprovalWorkflow: assert result.total == 1 assert result.pending_review == 1 + @pytest.mark.parametrize("allowed_routes", [None, [], ["llm_api_routes"], ["mcp_routes"]]) @pytest.mark.asyncio - async def test_get_submissions_sanitizes_for_view_only_admin(self): - """PROXY_ADMIN_VIEW_ONLY reviewing the submission queue must go through - the non-admin sanitizer that fetch/list endpoints use: url, - static_headers, env, env_vars, and credentials are all dropped. A - mutation swapping the gate back to the old partial-blank pattern (which - left url/static_headers/env and env-var names intact) would fail this.""" + async def test_get_submissions_sanitizes_for_view_only_admin(self, allowed_routes: list[str] | None): from litellm.proxy._types import MCPSubmissionsSummary from litellm.proxy.management_endpoints.mcp_management_endpoints import ( get_mcp_server_submissions, @@ -4596,6 +4592,7 @@ class TestMCPApprovalWorkflow: item = _leaky_list_server() item.approval_status = "pending_review" + item.spec_path = "https://example.com/spec.json?key=private" summary = MCPSubmissionsSummary(total=1, pending_review=1, active=0, rejected=0, items=[item]) with ( @@ -4609,11 +4606,15 @@ class TestMCPApprovalWorkflow: ), ): result = await get_mcp_server_submissions( - user_api_key_dict=generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY), + user_api_key_dict=UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, allowed_routes=allowed_routes + ), ) + assert (result.total, result.pending_review, result.active, result.rejected) == (1, 1, 0, 0) assert len(result.items) == 1 sanitized = result.items[0] + assert sanitized.spec_path is None assert sanitized.url is None assert sanitized.static_headers is None assert sanitized.env == {} @@ -4624,11 +4625,9 @@ class TestMCPApprovalWorkflow: assert item.url == "https://leaky.example.com/mcp?api_key=sk-embedded-in-url" assert item.static_headers == {"Authorization": "Bearer sk-secret-header"} + @pytest.mark.parametrize("allowed_routes", [None, [], ["llm_api_routes"], ["mcp_routes"]]) @pytest.mark.asyncio - async def test_get_submissions_full_admin_still_sees_secrets(self): - """The view-only redaction must not over-redact for a full PROXY_ADMIN, - who needs url/static_headers/env/env_vars to review the pending - submission. Only the explicit credentials field is cleared.""" + async def test_get_submissions_full_admin_preserves_review_fields(self, allowed_routes: list[str] | None): from litellm.proxy._types import MCPSubmissionsSummary from litellm.proxy.management_endpoints.mcp_management_endpoints import ( get_mcp_server_submissions, @@ -4636,6 +4635,7 @@ class TestMCPApprovalWorkflow: item = _leaky_list_server() item.approval_status = "pending_review" + item.spec_path = "https://example.com/spec.json?key=private" summary = MCPSubmissionsSummary(total=1, pending_review=1, active=0, rejected=0, items=[item]) with ( @@ -4649,11 +4649,14 @@ class TestMCPApprovalWorkflow: ), ): result = await get_mcp_server_submissions( - user_api_key_dict=generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN), + user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, allowed_routes=allowed_routes), ) + assert (result.total, result.pending_review, result.active, result.rejected) == (1, 1, 0, 0) assert len(result.items) == 1 raw = result.items[0] + assert raw.spec_path == item.spec_path + assert raw.approval_status == "pending_review" assert raw.url == "https://leaky.example.com/mcp?api_key=sk-embedded-in-url" assert raw.static_headers == {"Authorization": "Bearer sk-secret-header"} assert raw.env == {"UPSTREAM_TOKEN": "sk-secret-env"}