mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-20 00:11:50 +00:00
feat(mcp): native auth for GCP-managed MCP servers
This commit is contained in:
parent
35dc982692
commit
4dae85db50
13 changed files with 458 additions and 24 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<CreateMCPServerProps> = ({
|
|||
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<CreateMCPServerProps> = ({
|
|||
<Select.Option value="oauth2">OAuth</Select.Option>
|
||||
<Select.Option value="oauth2_token_exchange">OAuth Token Exchange (OBO)</Select.Option>
|
||||
<Select.Option value="aws_sigv4">AWS SigV4 (Bedrock AgentCore MCPs)</Select.Option>
|
||||
<Select.Option value="gcp_service_account">Google Cloud (GCP-managed MCPs)</Select.Option>
|
||||
<Select.Option value="true_passthrough">True Passthrough (no LiteLLM auth)</Select.Option>
|
||||
<Select.Option value="oauth_delegate">
|
||||
OAuth Delegate (client-supplied upstream token)
|
||||
|
|
@ -1307,6 +1310,47 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
</>
|
||||
)}
|
||||
|
||||
{transportType !== "stdio" && transportType !== "" && isGcpServiceAccountAuthType && (
|
||||
<>
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
For GCP-managed MCP servers (e.g. https://bigquery.googleapis.com/mcp). LiteLLM mints a Google access
|
||||
token for every request.
|
||||
</p>
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Service Account JSON
|
||||
<Tooltip title="Optional. Service account key JSON or a path to it. If blank, LiteLLM uses Application Default Credentials (e.g. the GKE workload identity of the proxy pod).">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name={["credentials", "gcp_credentials"]}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder='{"type": "service_account", ...} (optional — uses workload identity if blank)'
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
GCP Project ID
|
||||
<Tooltip title="Optional. Project billed for the request, sent as x-goog-user-project.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name={["credentials", "gcp_project_id"]}
|
||||
>
|
||||
<Input
|
||||
placeholder="my-gcp-project (optional)"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Stdio Configuration - only show for stdio transport */}
|
||||
<StdioConfiguration isVisible={transportType === "stdio"} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<MCPServerEditProps> = ({
|
|||
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<MCPServerEditProps> = ({
|
|||
<Select.Option value="oauth2">OAuth</Select.Option>
|
||||
<Select.Option value="oauth2_token_exchange">OAuth Token Exchange (OBO)</Select.Option>
|
||||
<Select.Option value="aws_sigv4">AWS SigV4 (Bedrock AgentCore MCPs)</Select.Option>
|
||||
<Select.Option value="gcp_service_account">Google Cloud (GCP-managed MCPs)</Select.Option>
|
||||
<Select.Option value="true_passthrough">True Passthrough (no LiteLLM auth)</Select.Option>
|
||||
<Select.Option value="oauth_delegate">
|
||||
OAuth Delegate (client-supplied upstream token)
|
||||
|
|
@ -1575,6 +1578,47 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
</>
|
||||
)}
|
||||
|
||||
{!isStdioTransport && isGcpServiceAccountAuthType && (
|
||||
<>
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
For GCP-managed MCP servers (e.g. https://bigquery.googleapis.com/mcp). LiteLLM mints a Google access
|
||||
token for every request.
|
||||
</p>
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Service Account JSON
|
||||
<Tooltip title="Optional. Service account key JSON or a path to it. If blank, LiteLLM uses Application Default Credentials (e.g. the GKE workload identity of the proxy pod).">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name={["credentials", "gcp_credentials"]}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder="Leave blank to keep existing"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
GCP Project ID
|
||||
<Tooltip title="Optional. Project billed for the request, sent as x-goog-user-project.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name={["credentials", "gcp_project_id"]}
|
||||
>
|
||||
<Input
|
||||
placeholder="my-gcp-project (optional)"
|
||||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Environment Variables Section */}
|
||||
<div className="mt-6">
|
||||
<EnvVarsSection />
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue