From 4dae85db50be1e186f1dba9f6849399602a014a8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:54:00 +0000 Subject: [PATCH] feat(mcp): native auth for GCP-managed MCP servers --- litellm/experimental_mcp_client/client.py | 61 ++++- litellm/proxy/_experimental/mcp_server/db.py | 7 + .../mcp_server/mcp_server_manager.py | 55 ++++- .../outbound_credentials/adapter.py | 4 +- .../mcp_management_endpoints.py | 28 ++- litellm/types/mcp.py | 10 + .../types/mcp_server/mcp_server_manager.py | 2 + .../mcp_server/test_mcp_gcp_auth.py | 220 ++++++++++++++++++ .../test_mcp_management_endpoints.py | 4 + .../_components/create_mcp_server.tsx | 44 ++++ .../_components/mcp_server_edit.tsx | 44 ++++ .../src/components/mcp_tools/types.tsx | 1 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 +- 13 files changed, 458 insertions(+), 24 deletions(-) create mode 100644 tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_gcp_auth.py diff --git a/litellm/experimental_mcp_client/client.py b/litellm/experimental_mcp_client/client.py index da711463a44..b24cb37debd 100644 --- a/litellm/experimental_mcp_client/client.py +++ b/litellm/experimental_mcp_client/client.py @@ -6,7 +6,9 @@ import asyncio import base64 import os from typing import ( + TYPE_CHECKING, Any, + AsyncGenerator, Awaitable, Callable, Dict, @@ -52,6 +54,9 @@ from litellm.types.mcp import ( MCPTransportType, ) +if TYPE_CHECKING: + from litellm.llms.vertex_ai.vertex_llm_base import VertexBase + def to_basic_auth(auth_value: str) -> str: """Convert auth value to Basic Auth format.""" @@ -192,6 +197,51 @@ class MCPSigV4Auth(httpx.Auth): yield request +class MCPGoogleAuth(httpx.Auth): + """ + httpx Auth class that attaches a Google OAuth 2.0 access token to each request. + + Used for GCP-managed MCP servers (e.g. https://bigquery.googleapis.com/mcp), which + authenticate with a Google access token. Credentials come from an explicit service + account JSON (inline or file path) or, when none is given, Application Default + Credentials, so a proxy running in GKE authenticates with its workload identity. + Tokens are cached and refreshed by the shared Vertex auth layer. + """ + + def __init__( + self, + gcp_credentials: Union[str, Dict[str, str], None] = None, + gcp_project_id: str | None = None, + vertex_base: "VertexBase | None" = None, + ): + from litellm.llms.vertex_ai.vertex_llm_base import VertexBase as _VertexBase + + self.gcp_credentials = gcp_credentials + self.gcp_project_id = gcp_project_id + self._vertex_base = vertex_base if vertex_base is not None else _VertexBase() + + def _apply_token(self, request: httpx.Request, token: str) -> None: + request.headers["Authorization"] = f"Bearer {token}" + if self.gcp_project_id: + request.headers["x-goog-user-project"] = self.gcp_project_id + + def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]: + token, _ = self._vertex_base.get_access_token( + credentials=self.gcp_credentials, + project_id=self.gcp_project_id, + ) + self._apply_token(request, token) + yield request + + async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]: + token, _ = await self._vertex_base.get_access_token_async( + credentials=self.gcp_credentials, + project_id=self.gcp_project_id, + ) + self._apply_token(request, token) + yield request + + class MCPClient: """ MCP Client supporting: @@ -213,6 +263,7 @@ class MCPClient: extra_headers: Optional[Dict[str, str]] = None, ssl_verify: Optional[VerifyTypes] = None, aws_auth: Optional[httpx.Auth] = None, + google_auth: httpx.Auth | None = None, resolved_auth: Optional[httpx.Auth] = None, sampling_callback: Optional[Callable] = None, elicitation_callback: Optional[Callable] = None, @@ -227,6 +278,7 @@ class MCPClient: self.extra_headers: Optional[Dict[str, str]] = extra_headers self.ssl_verify: Optional[VerifyTypes] = ssl_verify self._aws_auth: Optional[httpx.Auth] = aws_auth + self._google_auth: httpx.Auth | None = google_auth # A pre-resolved httpx.Auth (e.g. from the v2 credential resolver) attached to the # upstream client's auth= slot, taking precedence over the SigV4 aws_auth. self._resolved_auth: Optional[httpx.Auth] = resolved_auth @@ -442,9 +494,10 @@ class MCPClient: headers["Authorization"] = f"Bearer {self._mcp_auth_value}" elif isinstance(self._mcp_auth_value, dict): headers.update(self._mcp_auth_value) - # Note: aws_sigv4 auth is not handled here — SigV4 requires per-request - # signing (including the body hash), so it uses httpx.Auth flow instead - # of static headers. See MCPSigV4Auth and _create_httpx_client_factory(). + # Note: aws_sigv4 and gcp_service_account auth are not handled here — SigV4 + # requires per-request signing (including the body hash) and Google tokens + # need per-request refresh, so both use the httpx.Auth flow instead of static + # headers. See MCPSigV4Auth, MCPGoogleAuth and _create_httpx_client_factory(). # update the headers with the extra headers if self.extra_headers: headers.update(self.extra_headers) @@ -473,7 +526,7 @@ class MCPClient: # The MCP SDK's sse_client and streamable_http_client call this factory without # passing auth=, so the fallback is used: a v2-resolved auth if present, else the # SigV4 aws_auth. Both are None for the common case — no behavior change. - fallback_auth = self._resolved_auth if self._resolved_auth is not None else self._aws_auth + fallback_auth = self._resolved_auth or self._aws_auth or self._google_auth effective_auth = auth if auth is not None else fallback_auth return httpx.AsyncClient( headers=headers, diff --git a/litellm/proxy/_experimental/mcp_server/db.py b/litellm/proxy/_experimental/mcp_server/db.py index 9fe970f7fa9..745484a75de 100644 --- a/litellm/proxy/_experimental/mcp_server/db.py +++ b/litellm/proxy/_experimental/mcp_server/db.py @@ -401,6 +401,12 @@ def encrypt_credentials(credentials: MCPCredentials, encryption_key: Optional[st new_encryption_key=encryption_key, ) # aws_region_name and aws_service_name are NOT secrets — stored as-is + gcp_credentials = credentials.get("gcp_credentials") + if gcp_credentials is not None: + credentials["gcp_credentials"] = encrypt_value_helper( + value=gcp_credentials, + new_encryption_key=encryption_key, + ) return credentials @@ -416,6 +422,7 @@ def decrypt_credentials( "aws_access_key_id", "aws_secret_access_key", "aws_session_token", + "gcp_credentials", ] for field in secret_fields: value = credentials.get(field) # type: ignore[literal-required] diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 0ee74960293..40184f86abe 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -45,7 +45,7 @@ from litellm.constants import ( MCP_TOOL_LISTING_TIMEOUT, ) from litellm.exceptions import BlockedPiiEntityError, GuardrailRaisedException -from litellm.experimental_mcp_client.client import MCPClient, MCPSigV4Auth +from litellm.experimental_mcp_client.client import MCPClient, MCPGoogleAuth, MCPSigV4Auth from litellm.litellm_core_utils.url_utils import SSRFError, async_safe_get from litellm.llms.custom_httpx.http_handler import get_async_httpx_client from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( @@ -751,8 +751,28 @@ def _consumes_caller_authorization(server: MCPServer) -> bool: ) +_SELF_CREDENTIALED_AUTH_TYPES = frozenset({MCPAuth.aws_sigv4, MCPAuth.gcp_service_account}) + + +def _build_google_auth(server: MCPServer) -> MCPGoogleAuth | None: + """The Google access-token auth for a GCP-managed MCP server, or None for any other auth type.""" + if server.auth_type != MCPAuth.gcp_service_account: + return None + return MCPGoogleAuth( + gcp_credentials=server.gcp_credentials, + gcp_project_id=server.gcp_project_id, + ) + + _REGISTRY_DUMP_SECRET_FIELDS = frozenset( - {"authentication_token", "client_secret", "client_private_key", "aws_secret_access_key", "aws_session_token"} + { + "authentication_token", + "client_secret", + "client_private_key", + "aws_secret_access_key", + "aws_session_token", + "gcp_credentials", + } ) @@ -1226,7 +1246,7 @@ class MCPServerManager: if ( server.auth_type and server.auth_type != MCPAuth.none - and server.auth_type != MCPAuth.aws_sigv4 + and server.auth_type not in _SELF_CREDENTIALED_AUTH_TYPES and not server.authentication_token ): return @@ -1510,6 +1530,8 @@ class MCPServerManager: aws_service_name=server_config.get("aws_service_name", None), aws_role_name=server_config.get("aws_role_name", None), aws_session_name=server_config.get("aws_session_name", None), + gcp_credentials=server_config.get("gcp_credentials", None), + gcp_project_id=server_config.get("gcp_project_id", None), instructions=server_config.get("instructions", None), # Token Exchange (OBO) fields token_exchange_endpoint=server_config.get("token_exchange_endpoint", None), @@ -1900,6 +1922,7 @@ class MCPServerManager: # AWS SigV4 credential fields aws_creds = self._extract_aws_credentials(credentials_dict, credentials_are_encrypted) + gcp_creds = self._extract_gcp_credentials(credentials_dict, credentials_are_encrypted) scopes: Optional[list[str]] = None if credentials_dict: @@ -2008,6 +2031,8 @@ class MCPServerManager: aws_service_name=aws_creds.get("aws_service_name"), aws_role_name=aws_creds.get("aws_role_name"), aws_session_name=aws_creds.get("aws_session_name"), + gcp_credentials=gcp_creds.get("gcp_credentials"), + gcp_project_id=gcp_creds.get("gcp_project_id"), instructions=mcp_server.instructions, # Token exchange (OBO) fields: dedicated columns, with the credentials blob as a # back-compat fallback for servers persisted before the columns existed. @@ -3173,6 +3198,8 @@ class MCPServerManager: elicitation_callback=elicitation_cb, ) + google_auth = _build_google_auth(server) + # Create SigV4 auth if configured aws_auth = None if server.auth_type == MCPAuth.aws_sigv4: @@ -3194,6 +3221,7 @@ class MCPServerManager: timeout=(server.timeout if server.timeout is not None else MCP_CLIENT_TIMEOUT), extra_headers=extra_headers, aws_auth=aws_auth, + google_auth=google_auth, sampling_callback=sampling_cb, elicitation_callback=elicitation_cb, ) @@ -4041,6 +4069,23 @@ class MCPServerManager: "aws_session_name": credentials_dict.get("aws_session_name"), } + def _extract_gcp_credentials( + self, + credentials_dict: dict[str, str] | None, + credentials_are_encrypted: bool, + ) -> dict[str, str | None]: + """Extract and decrypt Google Cloud credential fields from credentials dict.""" + if not credentials_dict: + return {} + return { + "gcp_credentials": self._decrypt_credential_field( + credentials_dict.get("gcp_credentials"), + "gcp_credentials", + credentials_are_encrypted, + ), + "gcp_project_id": credentials_dict.get("gcp_project_id"), + } + def _extract_scopes(self, scopes_value: Any) -> Optional[list[str]]: if isinstance(scopes_value, str): scopes = [s.strip() for s in scopes_value.split() if s.strip()] @@ -5676,11 +5721,11 @@ class MCPServerManager: if server.requires_per_user_auth: should_skip_health_check = True # Skip if auth_type is not none and authentication_token is missing - # (except aws_sigv4 which uses its own credential fields) + # (except cloud-provider auth types which use their own credential fields) elif ( server.auth_type and server.auth_type != MCPAuth.none - and server.auth_type != MCPAuth.aws_sigv4 + and server.auth_type not in _SELF_CREDENTIALED_AUTH_TYPES and not server.authentication_token ): should_skip_health_check = True diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py index 565c489e77c..c9c4fe870d9 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py @@ -103,8 +103,8 @@ def to_server_spec(server: MCPServer) -> Optional[ServerSpec]: return ServerSpec(server_id=server.server_id, resource=resource, config=PassthroughConfig()) case MCPAuth.oauth2_token_exchange: return _token_exchange_spec(server, resource) - case MCPAuth.aws_sigv4: - return None # SigV4 is not migrated yet -> defer to v1 + case MCPAuth.aws_sigv4 | MCPAuth.gcp_service_account: + return None # per-request cloud-provider signing is not migrated yet -> defer to v1 assert_never(auth_type) diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index 89f28a30a84..b490d968335 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -21,7 +21,7 @@ import json import os from dataclasses import dataclass from datetime import datetime, timedelta, timezone -from typing import Any, Dict, Iterable, List, Literal, Optional, Set +from typing import Any, Dict, Iterable, List, Literal, Optional, Set, cast from fastapi import ( APIRouter, @@ -615,6 +615,20 @@ if MCP_AVAILABLE: ) -> List[LiteLLM_MCPServerTable]: return [_sanitize_mcp_server_for_virtual_key(server) for server in mcp_servers] + def _cloud_provider_credentials(existing_server: MCPServer) -> MCPCredentials: + candidates: tuple[tuple[str, str | None], ...] = ( + ("aws_access_key_id", existing_server.aws_access_key_id), + ("aws_secret_access_key", existing_server.aws_secret_access_key), + ("aws_session_token", existing_server.aws_session_token), + ("aws_region_name", existing_server.aws_region_name), + ("aws_service_name", existing_server.aws_service_name), + ("gcp_credentials", existing_server.gcp_credentials), + ("gcp_project_id", existing_server.gcp_project_id), + ) + return cast( # cast-ok: comprehension over literal MCPCredentials keys, unmodelable by TypedDict + MCPCredentials, {field: value for field, value in candidates if value} + ) + def _inherit_credentials_from_existing_server( payload: NewMCPServerRequest, ) -> NewMCPServerRequest: @@ -634,17 +648,7 @@ if MCP_AVAILABLE: inherited_credentials["client_secret"] = existing_server.client_secret if existing_server.scopes: inherited_credentials["scopes"] = existing_server.scopes - # AWS SigV4 fields - if existing_server.aws_access_key_id: - inherited_credentials["aws_access_key_id"] = existing_server.aws_access_key_id - if existing_server.aws_secret_access_key: - inherited_credentials["aws_secret_access_key"] = existing_server.aws_secret_access_key - if existing_server.aws_session_token: - inherited_credentials["aws_session_token"] = existing_server.aws_session_token - if existing_server.aws_region_name: - inherited_credentials["aws_region_name"] = existing_server.aws_region_name - if existing_server.aws_service_name: - inherited_credentials["aws_service_name"] = existing_server.aws_service_name + inherited_credentials.update(_cloud_provider_credentials(existing_server)) if not inherited_credentials: return payload diff --git a/litellm/types/mcp.py b/litellm/types/mcp.py index 377ba669082..ce3794c05b4 100644 --- a/litellm/types/mcp.py +++ b/litellm/types/mcp.py @@ -36,6 +36,7 @@ class MCPAuth(str, enum.Enum): authorization = "authorization" oauth2 = "oauth2" aws_sigv4 = "aws_sigv4" + gcp_service_account = "gcp_service_account" token = "token" oauth2_token_exchange = "oauth2_token_exchange" oauth2_id_jag = "oauth2_id_jag" @@ -61,6 +62,7 @@ MCPAuthType = Optional[ MCPAuth.authorization, MCPAuth.oauth2, MCPAuth.aws_sigv4, + MCPAuth.gcp_service_account, MCPAuth.token, MCPAuth.oauth2_token_exchange, MCPAuth.oauth2_id_jag, @@ -132,6 +134,14 @@ class MCPCredentials(TypedDict, total=False): aws_session_name: Optional[str] """Session name for STS AssumeRole (used in CloudTrail). Not a secret — stored unencrypted.""" + gcp_credentials: Optional[str] + """Google service account JSON (inline or file path) used to mint OAuth access tokens for + GCP-managed MCP servers. Optional — falls back to Application Default Credentials (e.g. the + GKE workload identity of the pod running the proxy).""" + + gcp_project_id: Optional[str] + """Google Cloud project billed for the request, sent as ``x-goog-user-project``. Not a secret — stored unencrypted.""" + audience: Optional[str] """ Target audience for OAuth 2.0 Token Exchange (RFC 8693). diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index b0af22e7c3f..13c79f4439f 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -83,6 +83,8 @@ class MCPServer(BaseModel): aws_service_name: Optional[str] = None # defaults to "bedrock-agentcore" aws_role_name: Optional[str] = None # IAM role ARN for STS AssumeRole aws_session_name: Optional[str] = None # session name for CloudTrail auditing + gcp_credentials: Optional[str] = None + gcp_project_id: Optional[str] = None # Token Exchange (OBO) fields token_exchange_endpoint: Optional[str] = None audience: Optional[str] = None diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_gcp_auth.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_gcp_auth.py new file mode 100644 index 00000000000..2fd88e79c29 --- /dev/null +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_gcp_auth.py @@ -0,0 +1,220 @@ +""" +Tests for Google Cloud authentication in MCP client. + +Covers the MCPGoogleAuth httpx.Auth subclass used for GCP-managed MCP servers +(e.g. https://bigquery.googleapis.com/mcp), plus config loading, client wiring, +and credential encryption for the gcp_service_account auth type. +""" + +import json +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +from litellm.experimental_mcp_client.client import MCPClient, MCPGoogleAuth +from litellm.types.mcp import MCPAuth, MCPTransport +from litellm.types.mcp_server.mcp_server_manager import MCPServer + +SERVICE_ACCOUNT_JSON = json.dumps({"type": "service_account", "project_id": "my-project"}) + + +def _vertex_base_stub(token: str = "ya29.test-token", project_id: str = "my-project") -> MagicMock: + stub = MagicMock() + stub.get_access_token.return_value = (token, project_id) + stub.get_access_token_async = AsyncMock(return_value=(token, project_id)) + return stub + + +def _request() -> httpx.Request: + return httpx.Request("POST", "https://bigquery.googleapis.com/mcp", json={"jsonrpc": "2.0"}) + + +class TestMCPGoogleAuth: + def test_auth_flow_sets_bearer_token(self): + vertex_base = _vertex_base_stub() + auth = MCPGoogleAuth(gcp_credentials=SERVICE_ACCOUNT_JSON, vertex_base=vertex_base) + + request = next(auth.auth_flow(_request())) + + assert request.headers["Authorization"] == "Bearer ya29.test-token" + assert "x-goog-user-project" not in request.headers + vertex_base.get_access_token.assert_called_once_with( + credentials=SERVICE_ACCOUNT_JSON, + project_id=None, + ) + + def test_auth_flow_sets_quota_project_header_when_configured(self): + auth = MCPGoogleAuth(gcp_project_id="billing-project", vertex_base=_vertex_base_stub()) + + request = next(auth.auth_flow(_request())) + + assert request.headers["x-goog-user-project"] == "billing-project" + + def test_auth_flow_uses_application_default_credentials_when_unset(self): + vertex_base = _vertex_base_stub(token="ya29.workload-identity") + auth = MCPGoogleAuth(vertex_base=vertex_base) + + request = next(auth.auth_flow(_request())) + + assert request.headers["Authorization"] == "Bearer ya29.workload-identity" + vertex_base.get_access_token.assert_called_once_with(credentials=None, project_id=None) + + def test_auth_flow_refreshes_token_per_request(self): + vertex_base = _vertex_base_stub() + vertex_base.get_access_token.side_effect = [("first", "p"), ("second", "p")] + auth = MCPGoogleAuth(vertex_base=vertex_base) + + first = next(auth.auth_flow(_request())) + second = next(auth.auth_flow(_request())) + + assert first.headers["Authorization"] == "Bearer first" + assert second.headers["Authorization"] == "Bearer second" + + @pytest.mark.asyncio + async def test_async_auth_flow_uses_async_token_path(self): + vertex_base = _vertex_base_stub(token="ya29.async-token") + auth = MCPGoogleAuth(gcp_credentials=SERVICE_ACCOUNT_JSON, vertex_base=vertex_base) + + request = await auth.async_auth_flow(_request()).__anext__() + + assert request.headers["Authorization"] == "Bearer ya29.async-token" + vertex_base.get_access_token.assert_not_called() + vertex_base.get_access_token_async.assert_awaited_once_with( + credentials=SERVICE_ACCOUNT_JSON, + project_id=None, + ) + + +class TestMCPClientGoogleAuth: + def test_factory_wires_google_auth_into_httpx_client(self): + google_auth = MCPGoogleAuth(vertex_base=_vertex_base_stub()) + client = MCPClient( + server_url="https://bigquery.googleapis.com/mcp", + transport_type=MCPTransport.http, + auth_type=MCPAuth.gcp_service_account, + google_auth=google_auth, + ) + + httpx_client = client._create_httpx_client_factory()( + headers={"Content-Type": "application/json"}, + timeout=httpx.Timeout(30.0), + ) + + assert httpx_client._auth is google_auth + + def test_google_auth_does_not_add_static_auth_headers(self): + client = MCPClient( + server_url="https://bigquery.googleapis.com/mcp", + transport_type=MCPTransport.http, + auth_type=MCPAuth.gcp_service_account, + google_auth=MCPGoogleAuth(vertex_base=_vertex_base_stub()), + ) + + assert "Authorization" not in client._get_auth_headers() + + +class TestMCPServerManagerGoogleAuth: + @pytest.mark.asyncio + async def test_load_config_parses_gcp_fields(self): + from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager + + manager = MCPServerManager() + await manager.load_servers_from_config( + { + "bigquery": { + "url": "https://bigquery.googleapis.com/mcp", + "transport": "http", + "auth_type": "gcp_service_account", + "gcp_credentials": SERVICE_ACCOUNT_JSON, + "gcp_project_id": "my-project", + } + } + ) + + server = next(iter(manager.config_mcp_servers.values())) + assert server.auth_type == MCPAuth.gcp_service_account + assert server.gcp_credentials == SERVICE_ACCOUNT_JSON + assert server.gcp_project_id == "my-project" + + @pytest.mark.asyncio + async def test_create_mcp_client_builds_google_auth(self): + from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager + + server = MCPServer( + server_id="test-gcp", + name="bigquery", + server_name="bigquery", + url="https://bigquery.googleapis.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.gcp_service_account, + gcp_credentials=SERVICE_ACCOUNT_JSON, + gcp_project_id="my-project", + ) + + client = await MCPServerManager()._create_mcp_client(server=server) + + assert isinstance(client._google_auth, MCPGoogleAuth) + assert client._google_auth.gcp_credentials == SERVICE_ACCOUNT_JSON + assert client._google_auth.gcp_project_id == "my-project" + + @pytest.mark.asyncio + async def test_create_mcp_client_without_gcp_auth(self): + from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager + + server = MCPServer( + server_id="test-bearer", + name="bearer", + server_name="bearer", + url="https://example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.bearer_token, + authentication_token="token", + ) + + client = await MCPServerManager()._create_mcp_client(server=server) + + assert client._google_auth is None + + def test_registry_dump_redacts_service_account_json(self): + from litellm.proxy._experimental.mcp_server.mcp_server_manager import _redacted_registry_dump + + server = MCPServer( + server_id="test-gcp", + name="bigquery", + url="https://bigquery.googleapis.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.gcp_service_account, + gcp_credentials=SERVICE_ACCOUNT_JSON, + ) + + dump = _redacted_registry_dump({"test-gcp": server})["test-gcp"] + + assert dump["gcp_credentials"] == "**REDACTED**" + + def test_build_from_table_decrypts_gcp_credentials(self, monkeypatch): + from litellm.proxy._experimental.mcp_server.db import decrypt_credentials, encrypt_credentials + + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-key") + + credentials = encrypt_credentials( + {"gcp_credentials": SERVICE_ACCOUNT_JSON, "gcp_project_id": "my-project"}, + encryption_key=None, + ) + assert credentials["gcp_credentials"] != SERVICE_ACCOUNT_JSON + assert credentials["gcp_project_id"] == "my-project" + + assert decrypt_credentials(credentials)["gcp_credentials"] == SERVICE_ACCOUNT_JSON + + def test_extract_gcp_credentials_from_unencrypted_blob(self): + from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager + + extracted = MCPServerManager()._extract_gcp_credentials( + {"gcp_credentials": SERVICE_ACCOUNT_JSON, "gcp_project_id": "my-project"}, + credentials_are_encrypted=False, + ) + + assert extracted == { + "gcp_credentials": SERVICE_ACCOUNT_JSON, + "gcp_project_id": "my-project", + } 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 e1aaf398f97..69028dfa5e9 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 @@ -1428,6 +1428,8 @@ class TestTemporaryMCPSessionEndpoints: existing_server.aws_session_token = None existing_server.aws_region_name = None existing_server.aws_service_name = None + existing_server.gcp_credentials = None + existing_server.gcp_project_id = None mock_manager = MagicMock() mock_manager.get_mcp_server_by_id.return_value = existing_server @@ -1686,6 +1688,8 @@ class TestTemporaryMCPSessionEndpoints: aws_session_token=None, aws_region_name=None, aws_service_name=None, + gcp_credentials=None, + gcp_project_id=None, ) built_server = generate_mock_mcp_server_config_record(server_id="temp-server") mock_manager = MagicMock() diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/create_mcp_server.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/create_mcp_server.tsx index b21a5218c20..f734e5b9fb1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/create_mcp_server.tsx @@ -61,6 +61,7 @@ const AUTH_TYPES_REQUIRING_CREDENTIALS = [ AUTH_TYPE.OAUTH2, AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE, AUTH_TYPE.AWS_SIGV4, + AUTH_TYPE.GCP_SERVICE_ACCOUNT, AUTH_TYPE.TRUE_PASSTHROUGH, AUTH_TYPE.OAUTH_DELEGATE, ]; @@ -140,6 +141,7 @@ const CreateMCPServer: React.FC = ({ const isOAuthAuthType = authType === AUTH_TYPE.OAUTH2; const isTokenExchangeAuthType = authType === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE; const isAwsSigV4AuthType = authType === AUTH_TYPE.AWS_SIGV4; + const isGcpServiceAccountAuthType = authType === AUTH_TYPE.GCP_SERVICE_ACCOUNT; const isM2MFlow = isOAuthAuthType && formValues.oauth_flow_type === OAUTH_FLOW.M2M; const persistCreateUiState = () => { @@ -1079,6 +1081,7 @@ const CreateMCPServer: React.FC = ({ OAuth OAuth Token Exchange (OBO) AWS SigV4 (Bedrock AgentCore MCPs) + Google Cloud (GCP-managed MCPs) True Passthrough (no LiteLLM auth) OAuth Delegate (client-supplied upstream token) @@ -1307,6 +1310,47 @@ const CreateMCPServer: React.FC = ({ )} + {transportType !== "stdio" && transportType !== "" && isGcpServiceAccountAuthType && ( + <> +

+ For GCP-managed MCP servers (e.g. https://bigquery.googleapis.com/mcp). LiteLLM mints a Google access + token for every request. +

+ + Service Account JSON + + + + + } + name={["credentials", "gcp_credentials"]} + > + + + + GCP Project ID + + + + + } + name={["credentials", "gcp_project_id"]} + > + + + + )} + {/* Stdio Configuration - only show for stdio transport */} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx index 8646ab192c9..704f6a0ec11 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mcp_server_edit.tsx @@ -63,6 +63,7 @@ const AUTH_TYPES_REQUIRING_CREDENTIALS = [ AUTH_TYPE.OAUTH2, AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE, AUTH_TYPE.AWS_SIGV4, + AUTH_TYPE.GCP_SERVICE_ACCOUNT, AUTH_TYPE.TRUE_PASSTHROUGH, AUTH_TYPE.OAUTH_DELEGATE, ]; @@ -102,6 +103,7 @@ const MCPServerEdit: React.FC = ({ const isOAuthAuthType = authType === AUTH_TYPE.OAUTH2; const isTokenExchangeAuthType = authType === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE; const isAwsSigV4AuthType = authType === AUTH_TYPE.AWS_SIGV4; + const isGcpServiceAccountAuthType = authType === AUTH_TYPE.GCP_SERVICE_ACCOUNT; const oauthFlowTypeValue = Form.useWatch("oauth_flow_type", form) as string | undefined; const isM2MFlow = isOAuthAuthType && oauthFlowTypeValue === OAUTH_FLOW.M2M; // Watch reflects a live toggle when the delegate switch is mounted; fall back to @@ -1115,6 +1117,7 @@ const MCPServerEdit: React.FC = ({ OAuth OAuth Token Exchange (OBO) AWS SigV4 (Bedrock AgentCore MCPs) + Google Cloud (GCP-managed MCPs) True Passthrough (no LiteLLM auth) OAuth Delegate (client-supplied upstream token) @@ -1575,6 +1578,47 @@ const MCPServerEdit: React.FC = ({ )} + {!isStdioTransport && isGcpServiceAccountAuthType && ( + <> +

+ For GCP-managed MCP servers (e.g. https://bigquery.googleapis.com/mcp). LiteLLM mints a Google access + token for every request. +

+ + Service Account JSON + + + + + } + name={["credentials", "gcp_credentials"]} + > + + + + GCP Project ID + + + + + } + name={["credentials", "gcp_project_id"]} + > + + + + )} + {/* Environment Variables Section */}
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index 038dc5cb2ca..37561704f4b 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -41,6 +41,7 @@ export const AUTH_TYPE = { OAUTH2: "oauth2", OAUTH2_TOKEN_EXCHANGE: "oauth2_token_exchange", AWS_SIGV4: "aws_sigv4", + GCP_SERVICE_ACCOUNT: "gcp_service_account", TRUE_PASSTHROUGH: "true_passthrough", OAUTH_DELEGATE: "oauth_delegate", }; diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index aafab811b83..1f7cbb42032 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -27423,7 +27423,7 @@ export interface components { /** Alias */ alias?: string | null; /** Auth Type */ - auth_type?: ("none" | "api_key" | "bearer_token" | "basic" | "authorization" | "oauth2" | "aws_sigv4" | "token" | "oauth2_token_exchange" | "oauth2_id_jag" | "true_passthrough" | "oauth_delegate") | null; + auth_type?: ("none" | "api_key" | "bearer_token" | "basic" | "authorization" | "oauth2" | "aws_sigv4" | "gcp_service_account" | "token" | "oauth2_token_exchange" | "oauth2_id_jag" | "true_passthrough" | "oauth_delegate") | null; /** Mcp Info */ mcp_info?: { [key: string]: unknown;