diff --git a/litellm/experimental_mcp_client/client.py b/litellm/experimental_mcp_client/client.py index be9d2b88e99..f0a1bff8fdc 100644 --- a/litellm/experimental_mcp_client/client.py +++ b/litellm/experimental_mcp_client/client.py @@ -56,6 +56,9 @@ from litellm.types.mcp import ( MCPStdioConfig, MCPTransport, MCPTransportType, + credential_redirect_hook, + has_header, + without_header, ) @@ -273,6 +276,7 @@ class MCPClient: transport_type: MCPTransportType = MCPTransport.http, auth_type: MCPAuthType = None, auth_value: str | dict[str, str] | None = None, + auth_header_name: str | None = None, timeout: float | None = None, stdio_config: MCPStdioConfig | None = None, extra_headers: dict[str, str] | None = None, @@ -288,6 +292,11 @@ class MCPClient: self.auth_type: MCPAuthType = auth_type self.timeout: float = timeout if timeout is not None else MCP_CLIENT_TIMEOUT self._mcp_auth_value: str | dict[str, str] | None = None + # The one place this client decides which header its credential occupies: the operator's + # configured slot on the v1 path, or the slot the v2 resolver's auth object already owns. + # Every consumer reads this rather than re-deriving it, since each re-derivation so far + # picked up a different bug. + self._credential_slot: str | None = auth_header_name or getattr(resolved_auth, "header_name", None) self.stdio_config: MCPStdioConfig | None = stdio_config self.extra_headers: dict[str, str] | None = extra_headers self.ssl_verify: VerifyTypes | None = ssl_verify @@ -501,26 +510,33 @@ class MCPClient: else: self._mcp_auth_value = mcp_auth_value + def _header_slot(self, default: str) -> str: + return self._credential_slot or default + def _get_auth_headers(self) -> dict: """Generate authentication headers based on auth type.""" headers: Final = {} if self._mcp_auth_value: if isinstance(self._mcp_auth_value, str): if self.auth_type == MCPAuth.bearer_token: - headers["Authorization"] = f"Bearer {strip_auth_scheme(self._mcp_auth_value, 'Bearer')}" + static_bearer: Final = strip_auth_scheme(self._mcp_auth_value, "Bearer") + headers[self._header_slot("Authorization")] = f"Bearer {static_bearer}" elif self.auth_type == MCPAuth.basic: - headers["Authorization"] = f"Basic {self._mcp_auth_value}" + headers[self._header_slot("Authorization")] = f"Basic {self._mcp_auth_value}" elif self.auth_type == MCPAuth.api_key: - headers["X-API-Key"] = self._mcp_auth_value + headers[self._header_slot("X-API-Key")] = self._mcp_auth_value elif self.auth_type == MCPAuth.authorization: # This auth type means the caller owns the whole header value. - headers["Authorization"] = self._mcp_auth_value + headers[self._header_slot("Authorization")] = self._mcp_auth_value elif self.auth_type == MCPAuth.oauth2: - headers["Authorization"] = f"Bearer {strip_auth_scheme(self._mcp_auth_value, 'Bearer')}" + oauth2_bearer: Final = strip_auth_scheme(self._mcp_auth_value, "Bearer") + headers[self._header_slot("Authorization")] = f"Bearer {oauth2_bearer}" elif self.auth_type == MCPAuth.token: - headers["Authorization"] = f"token {strip_auth_scheme(self._mcp_auth_value, 'token')}" + scheme_token: Final = strip_auth_scheme(self._mcp_auth_value, "token") + headers[self._header_slot("Authorization")] = f"token {scheme_token}" elif self.auth_type == MCPAuth.oauth2_token_exchange: - headers["Authorization"] = f"Bearer {strip_auth_scheme(self._mcp_auth_value, 'Bearer')}" + exchanged_bearer: Final = strip_auth_scheme(self._mcp_auth_value, "Bearer") + headers[self._header_slot("Authorization")] = f"Bearer {exchanged_bearer}" 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 @@ -528,7 +544,14 @@ class MCPClient: # of static headers. See MCPSigV4Auth and _create_httpx_client_factory(). # update the headers with the extra headers if self.extra_headers: - headers.update(self.extra_headers) + # Mirrors _resolve_v2_auth: when the operator named a slot for the credential the + # gateway resolved, no injected header may shadow it, case-insensitively, since HTTP + # header names are. Without a configured slot the old precedence stands unchanged. + slot: Final = self._credential_slot + injected: Final = ( + without_header(self.extra_headers, slot) if slot and has_header(headers, slot) else self.extra_headers + ) + headers.update(injected or {}) return _strip_header_whitespace(headers) def _create_httpx_client_factory(self) -> Callable[..., httpx.AsyncClient]: @@ -556,12 +579,14 @@ class MCPClient: # SigV4 aws_auth. Both are None for the common case — no behavior change. fallback_auth: Final = self._resolved_auth if self._resolved_auth is not None else self._aws_auth effective_auth: Final = auth if auth is not None else fallback_auth + guard: Final = credential_redirect_hook(self.server_url, self._credential_slot) return httpx.AsyncClient( headers=headers, timeout=timeout, auth=effective_auth, verify=ssl_config, follow_redirects=True, + event_hooks={"request": [guard]} if guard else {}, ) return factory diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 308813039ca..a708bbe5dbd 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -34,6 +34,7 @@ from mcp.types import ( ) from mcp.types import Tool as MCPTool from pydantic import AnyUrl, BaseModel +from typing_extensions import ReadOnly import litellm from litellm._logging import verbose_logger @@ -72,6 +73,7 @@ from litellm.proxy._experimental.mcp_server.oauth2_token_cache import ( MCPPerUserTokenCache, mcp_per_user_token_cache, resolve_mcp_auth, + resolved_token_header, ) from litellm.proxy._experimental.mcp_server.oauth_utils import ( _redact_mcp_resource_url, @@ -99,6 +101,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.token_exchange_ build_token_exchanger, ) from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( + DEFAULT_CREDENTIAL_HEADER, AuthorizationCodeConfig, ClientCredentialsConfig, CredError, @@ -153,6 +156,8 @@ from litellm.types.mcp import ( MCPAuth, MCPStdioConfig, MCPTokenEndpointAuthMethod, + has_header, + without_header, ) from litellm.types.mcp_server.mcp_server_manager import ( MCPInfo, @@ -349,6 +354,7 @@ class MCPServerConfig(TypedDict, total=False): audience: str subject_token_type: str upstream_resource: str + upstream_token_header: ReadOnly[str] id_jag_resource_token_endpoint: str id_jag_resource: str client_private_key: str @@ -828,18 +834,6 @@ def _should_strip_caller_authorization( ) -def _without_authorization( - headers: dict[str, str] | None, -) -> dict[str, str] | None: - """A copy of ``headers`` with any ``Authorization`` key removed (case-insensitive), or - None if nothing remains. Drops only the credential, keeping other forwarded headers. - """ - if not headers: - return None - filtered: Final = {k: v for k, v in headers.items() if k.lower() != "authorization"} - return filtered or None - - def _format_byok_openapi_auth_header(mcp_server: MCPServer, mcp_auth_header: str) -> str: """Format a raw BYOK credential for OpenAPI tool ``Authorization`` injection. @@ -914,7 +908,9 @@ def _resolve_openapi_tool_auth( if isinstance(per_server, dict): authorization: Final = next((v for k, v in per_server.items() if k.lower() == "authorization"), None) - merged: Final = merge_mcp_headers(extra_headers=forwarded, static_headers=_without_authorization(per_server)) + merged: Final = merge_mcp_headers( + extra_headers=forwarded, static_headers=without_header(per_server, DEFAULT_CREDENTIAL_HEADER) + ) if authorization is None: byok: Final = _format_byok_openapi_auth_header(mcp_server, mcp_auth_header) if mcp_auth_header else None return byok, merged, mcp_auth_header @@ -981,7 +977,7 @@ def _client_forwarded_authorization_headers( raw_headers=raw_headers, user_api_key_auth=user_api_key_auth, ): - return _without_authorization(extra_headers) + return without_header(extra_headers, DEFAULT_CREDENTIAL_HEADER) return extra_headers @@ -994,7 +990,7 @@ def _take_forwarded_authorization( if not headers: return None, headers value: Final = next((v for k, v in headers.items() if k.lower() == "authorization"), None) - return value, _without_authorization(headers) + return value, without_header(headers, DEFAULT_CREDENTIAL_HEADER) def _passthrough_token_from_mcp_auth_header( @@ -2166,6 +2162,7 @@ class MCPServerManager: DEFAULT_SUBJECT_TOKEN_TYPE, ), upstream_resource=server_config.get("upstream_resource", None), + upstream_token_header=server_config.get("upstream_token_header", None), # ID-JAG fields id_jag_resource_token_endpoint=server_config.get("id_jag_resource_token_endpoint", None), id_jag_resource=server_config.get("id_jag_resource", None), @@ -2698,6 +2695,7 @@ class MCPServerManager: or (credentials_dict.get("subject_token_type") if credentials_dict else None) or DEFAULT_SUBJECT_TOKEN_TYPE, upstream_resource=(credentials_dict.get("upstream_resource") if credentials_dict else None), + upstream_token_header=(credentials_dict.get("upstream_token_header") if credentials_dict else None), # ID-JAG fields — read from credentials JSON blob id_jag_resource_token_endpoint=( credentials_dict.get("id_jag_resource_token_endpoint") if credentials_dict else None @@ -3525,10 +3523,9 @@ class MCPServerManager: case Ok(auth): # NoOpAuth has no header_name and so never conflicts. header_name: Final[str | None] = getattr(auth, "header_name", None) - conflicts: Final = bool( - header_name and extra_headers and any(key.lower() == header_name.lower() for key in extra_headers) - ) - if not conflicts: + if header_name is None or not extra_headers: + return auth, extra_headers + if not has_header(extra_headers, header_name): return auth, extra_headers if isinstance( spec.config, @@ -3540,9 +3537,10 @@ class MCPServerManager: # guardrail such as MCPJWTSigner, static_headers, or any other injected # Authorization must NOT shadow it (otherwise the upstream gets e.g. the # signer's JWT instead of the minted token and rejects it, and for M2M the - # one-shot 401 refetch is lost with it). Drop the conflicting header so the - # resolved token reaches upstream. - return auth, _without_authorization(extra_headers) + # one-shot 401 refetch is lost with it). Drop only the header the resolved + # credential is about to occupy, so a static credential the operator aimed at a + # DIFFERENT header still reaches upstream. + return auth, without_header(extra_headers, header_name) # Other modes: an Authorization already supplied via extra_headers (a forwarded caller # header or static_headers) is intentional and wins; v1 applies those last. return None, extra_headers @@ -3650,6 +3648,7 @@ class MCPServerManager: ): spec = None auth_value: Final = await resolve_mcp_auth(resolved_server, mcp_auth_header) if spec is None else None + auth_header_name: Final = resolved_token_header(resolved_server, mcp_auth_header) if spec is None else None # Create sampling and elicitation callbacks for this client sampling_cb = ( @@ -3758,6 +3757,7 @@ class MCPServerManager: transport_type=transport, auth_type=resolved_server.auth_type, auth_value=auth_value, + auth_header_name=auth_header_name, timeout=(resolved_server.timeout if resolved_server.timeout is not None else MCP_CLIENT_TIMEOUT), extra_headers=extra_headers, aws_auth=aws_auth, @@ -5304,7 +5304,7 @@ class MCPServerManager: raw_headers=raw_headers, user_api_key_auth=user_api_key_auth, ): - extra_headers = _without_authorization(extra_headers) + extra_headers = without_header(extra_headers, DEFAULT_CREDENTIAL_HEADER) elif mcp_server.is_client_forwarded_token: extra_headers = _client_forwarded_authorization_headers( mcp_server=mcp_server, diff --git a/litellm/proxy/_experimental/mcp_server/oauth2_token_cache.py b/litellm/proxy/_experimental/mcp_server/oauth2_token_cache.py index b3f1da51074..a4ef970b87a 100644 --- a/litellm/proxy/_experimental/mcp_server/oauth2_token_cache.py +++ b/litellm/proxy/_experimental/mcp_server/oauth2_token_cache.py @@ -7,6 +7,7 @@ with ``client_id``, ``client_secret``, and ``token_url``. import asyncio import hashlib +from collections.abc import Mapping from typing import TYPE_CHECKING, Final import httpx @@ -313,9 +314,26 @@ async def resolve_mcp_auth( 1. ``mcp_auth_header`` — per-request/per-user override 2. OAuth2 client_credentials token — auto-fetched and cached 3. ``server.authentication_token`` — static token from config/DB + + ``resolved_token_header`` answers, for the same two inputs, which header the value belongs in. """ if mcp_auth_header: return mcp_auth_header if server.has_client_credentials: return await mcp_oauth2_token_cache.async_get_token(server) return server.authentication_token + + +def resolved_token_header( + server: "MCPServer", + mcp_auth_header: str | Mapping[str, str] | None = None, +) -> str | None: + """Which upstream header the value ``resolve_mcp_auth`` just returned belongs in. + + ``None`` means keep the auth_type default. A caller-supplied ``mcp_auth_header`` is the caller's + own credential aimed at the slot the upstream normally uses, so it never moves; only the values + the gateway resolved from its own config (the minted M2M token, the static token) follow + ``upstream_token_header``. Same inputs and same branch order as ``resolve_mcp_auth``, so the two + cannot disagree about which case they are in. + """ + return None if mcp_auth_header else server.upstream_token_header diff --git a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py index 083a98cdd36..16f58ef5b76 100644 --- a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py +++ b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py @@ -47,12 +47,14 @@ def sanitize_openapi_tool_name(raw_name: str) -> str: from litellm._logging import verbose_logger from litellm.litellm_core_utils.url_utils import async_safe_get from litellm.llms.custom_httpx.http_handler import ( + AsyncHTTPHandler, get_async_httpx_client, httpxSpecialProvider, ) from litellm.proxy._experimental.mcp_server.tool_registry import ( global_mcp_tool_registry, ) +from litellm.types.mcp import credential_redirect_hook, custom_credential_slot class _OpenAPIJSONSchema(TypedDict, total=False): @@ -119,6 +121,10 @@ _request_resolved_auth_headers: Final[contextvars.ContextVar[dict[str, str] | No "_request_resolved_auth_headers", default=None ) +_request_upstream_url: Final[contextvars.ContextVar[str | None]] = contextvars.ContextVar( + "_request_upstream_url", default=None +) + def _sanitize_path_parameter_value(param_value: object, param_name: str) -> str: """Ensure path params cannot introduce directory traversal.""" @@ -349,6 +355,35 @@ def build_input_schema(operation: _OpenAPIOperation) -> dict[str, object]: } +async def _drop_credential_across_origin(request: httpx.Request) -> None: + """Apply this request's cross-origin credential guard, if it needs one. + + Reads the per-request context rather than closing over it so the hook is one stable object, which + keeps the guarded client cacheable. A closure would key a new entry per call, and the handler it + built would never be closed. + """ + guard: Final = credential_redirect_hook( + _request_upstream_url.get() or "", custom_credential_slot(_request_resolved_auth_headers.get()) + ) + if guard is not None: + await guard(request) + + +def _upstream_client() -> AsyncHTTPHandler: + """The HTTP client for one upstream call, guarded when a credential rides a custom slot. + + A resolved credential outside ``Authorization`` is not stripped across origins by the client + itself, so this arm installs the same hook the MCP client uses. Both variants come from the + shared cache, so a guarded call reuses its connection pool like any other. + """ + if custom_credential_slot(_request_resolved_auth_headers.get()) is None: + return get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP) + return get_async_httpx_client( + llm_provider=httpxSpecialProvider.MCP, + params={"event_hooks": {"request": [_drop_credential_across_origin]}}, + ) + + def _merge_openapi_tool_request_headers( static_headers: dict[str, str], ) -> dict[str, str]: @@ -510,8 +545,9 @@ def create_tool_function( except (json.JSONDecodeError, TypeError): json_body = {"data": body_value} - client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP) + client: Final = _upstream_client() upstream: Final = server_label or f"{original_method.upper()} {path}" + url_token: Final = _request_upstream_url.set(url) try: if original_method == "get": @@ -529,6 +565,8 @@ def create_tool_function( except MaskedHTTPStatusError as e: _raise_for_upstream_failure(e.response, upstream, relays_upstream_auth) raise + finally: + _request_upstream_url.reset(url_token) _raise_for_upstream_failure(response, upstream, relays_upstream_auth) return response.text diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/__init__.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/__init__.py index a5dc75e3829..d61f8395677 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/__init__.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/__init__.py @@ -21,6 +21,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.result import ( Result, ) from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( + DEFAULT_CREDENTIAL_HEADER, Ambient, ApiKeyConfig, ApiKeySource, @@ -35,6 +36,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( ClientCredentialsConfig, ClientSecretAuth, CredError, + HeaderCarrier, IdJagConfig, NoneConfig, PassthroughConfig, @@ -45,9 +47,11 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( Subject, TokenExchangeConfig, parse_auth_spec_kind, + validate_header_name, ) __all__ = [ + "DEFAULT_CREDENTIAL_HEADER", "Ambient", "ApiKeyConfig", "ApiKeySource", @@ -63,6 +67,7 @@ __all__ = [ "ClientSecretAuth", "CredError", "Error", + "HeaderCarrier", "IdJagConfig", "NoOpAuth", "NoneConfig", @@ -78,4 +83,5 @@ __all__ = [ "TokenExchangeConfig", "UpstreamCredentialProvider", "parse_auth_spec_kind", + "validate_header_name", ] diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py index 98e239b1d1d..4458ac7f190 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py @@ -20,6 +20,7 @@ from typing_extensions import assert_never from litellm.proxy._experimental.mcp_server.oauth_utils import resolve_upstream_resource from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( + DEFAULT_CREDENTIAL_HEADER, ApiKeyConfig, AuthorizationCodeConfig, ClientAuth, @@ -45,6 +46,15 @@ _TOKEN_EXCHANGE_SUBJECT_TOKEN_DEFAULT: Final = "urn:ietf:params:oauth:token-type _ID_JAG_SUBJECT_TOKEN_DEFAULT: Final = "urn:ietf:params:oauth:token-type:id_token" +def token_header(server: MCPServer) -> str: + """The upstream header this server's resolved credential occupies. + + One owner for every arm, so no spec builder spells the default itself and a server can never + hand two arms different answers. + """ + return server.upstream_token_header or DEFAULT_CREDENTIAL_HEADER + + def to_subject(user_api_key_auth: UserAPIKeyAuth | None, subject_token: str | None) -> Subject: """Map v1's authenticated principal onto the resolver's Subject. @@ -122,7 +132,7 @@ def _oauth2_spec(server: MCPServer, resource: str) -> ServerSpec | None: return ServerSpec( server_id=server.server_id, resource=resource, - config=AuthorizationCodeConfig(), + config=AuthorizationCodeConfig(header_name=token_header(server)), ) return None @@ -140,6 +150,7 @@ def _client_credentials_spec(server: MCPServer, resource: str) -> ServerSpec: server_id=server.server_id, resource=resource, config=ClientCredentialsConfig( + header_name=token_header(server), client_id=server.client_id, client_secret=SecretStr(server.client_secret) if server.client_secret else None, token_url=server.effective_token_url, @@ -173,6 +184,7 @@ def _token_exchange_spec(server: MCPServer, resource: str) -> ServerSpec | None: server_id=server.server_id, resource=resource, config=TokenExchangeConfig( + header_name=token_header(server), profile=profile, subject_token_type=server.subject_token_type or DEFAULT_SUBJECT_TOKEN_TYPE, token_exchange_endpoint=endpoint, @@ -206,7 +218,7 @@ def _shared_key_spec( server_id=server.server_id, resource=resource, config=ApiKeyConfig( - header_name=header_name, + header_name=server.upstream_token_header or header_name, value_prefix=value_prefix, key_source=SharedKey(value=SecretStr(value)), ), @@ -231,6 +243,7 @@ def _id_jag_spec(server: MCPServer, resource: str) -> ServerSpec | None: server_id=server.server_id, resource=resource, config=IdJagConfig( + header_name=token_header(server), org_token_endpoint=org_token_endpoint, resource_token_endpoint=resource_token_endpoint, client_id=client_id, diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/client_credentials.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/client_credentials.py index d0053fbe0a8..da00abfe604 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/client_credentials.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/client_credentials.py @@ -50,6 +50,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.result import ( from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( ClientCredentialsConfig, CredError, + HeaderCarrier, ) @@ -328,14 +329,21 @@ class ClientCredentialsBearerAuth(httpx.Auth): refetch fails, or the retried request 401s again, the upstream's response stands. """ - def __init__(self, access_token: str, refetch: Callable[[str], Awaitable[str | None]]) -> None: - self.header_name = "Authorization" + def __init__( + self, + access_token: str, + refetch: Callable[[str], Awaitable[str | None]], + carrier: HeaderCarrier, + ) -> None: + self._carrier = carrier + self.header_name = carrier.header_name self._access_token = SecretStr(access_token) self._refetch = refetch async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]: token: Final = self._access_token.get_secret_value() - request.headers[self.header_name] = f"Bearer {token}" + name, value = self._carrier.header(token) + request.headers[name] = value response: Final = yield request if response.status_code != 401: return @@ -343,7 +351,8 @@ class ClientCredentialsBearerAuth(httpx.Auth): if fresh is None: return self._access_token = SecretStr(fresh) - request.headers[self.header_name] = f"Bearer {fresh}" + fresh_name, fresh_value = self._carrier.header(fresh) + request.headers[fresh_name] = fresh_value yield request def sync_auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]: diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py index 94c59962b70..3af7b51f432 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py @@ -145,8 +145,8 @@ class UpstreamCredentialProvider: return await self._token_exchange(subject, server, config) case IdJagConfig() as config: return await self._id_jag(subject, server, config) - case AuthorizationCodeConfig(): - return await self._authorization_code(subject, server) + case AuthorizationCodeConfig() as config: + return await self._authorization_code(subject, server, config) case AwsSigV4Config(): return _not_implemented(AuthSpecKind.aws_sigv4) assert_never(server.config) @@ -284,15 +284,19 @@ class UpstreamCredentialProvider: match await self._exchanged_tokens.get_or_compute(slot, _exchange, fingerprint=fingerprint): case Ok(access_token): - return Ok(StaticHeaderAuth(f"Bearer {access_token}")) + header_name, header_value = config.header(access_token) + return Ok(StaticHeaderAuth(header_value, header_name=header_name)) case Error(err): return Error(err) - async def _authorization_code(self, subject: Subject, server: ServerSpec) -> Result[StaticHeaderAuth, CredError]: + async def _authorization_code( + self, subject: Subject, server: ServerSpec, config: AuthorizationCodeConfig + ) -> Result[StaticHeaderAuth, CredError]: token: Final = await self._authz_token(subject, server) if token is None: return Error(CredError.of_unauthorized("Authorization required: complete the OAuth flow for this server.")) - return Ok(StaticHeaderAuth(f"Bearer {token.access_token}", header_name="Authorization")) + header_name, header_value = config.header(token.access_token) + return Ok(StaticHeaderAuth(header_value, header_name=header_name)) async def _client_credentials( self, server_id: str, config: ClientCredentialsConfig @@ -307,7 +311,7 @@ class UpstreamCredentialProvider: match await self._client_credentials_source.get(server_id, config): case Ok(token): refetch: Final = partial(self._client_credentials_source.refetch, server_id, config) - return Ok(ClientCredentialsBearerAuth(token.access_token, refetch)) + return Ok(ClientCredentialsBearerAuth(token.access_token, refetch, config)) case Error(err): return Error(err) @@ -332,7 +336,8 @@ class UpstreamCredentialProvider: inbound.get_secret_value(), server, config, tenant_id=subject.tenant_id ): case Ok(token): - return Ok(StaticHeaderAuth(f"Bearer {token.access_token}", header_name="Authorization")) + header_name, header_value = config.header(token.access_token) + return Ok(StaticHeaderAuth(header_value, header_name=header_name)) case Error(err): return Error(err) diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py index ce9948f0448..67aad3e443e 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py @@ -31,7 +31,7 @@ from enum import Enum from typing import Annotated, Final, Literal from expression import case, tag, tagged_union -from pydantic import BaseModel, ConfigDict, Field, SecretStr +from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator from typing_extensions import assert_never from litellm.proxy._experimental.mcp_server.outbound_credentials.result import ( @@ -39,7 +39,11 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.result import ( Ok, Result, ) -from litellm.types.mcp import DEFAULT_SUBJECT_TOKEN_TYPE +from litellm.types.mcp import ( + DEFAULT_CREDENTIAL_HEADER, + DEFAULT_SUBJECT_TOKEN_TYPE, + normalize_upstream_header_name, +) class AuthSpecKind(str, Enum): @@ -161,7 +165,52 @@ class CredError: assert_never(self.tag) -class AuthorizationCodeConfig(BaseModel): +def validate_header_name(raw: str) -> Result[str, CredError]: + """``normalize_upstream_header_name`` with this package's error-as-value policy. + + The grammar itself lives in ``litellm.types.mcp`` so the v1 model, the management endpoint and + this vocabulary all judge a header name the same way while each keeps its own failure shape. + """ + normalized: Final = normalize_upstream_header_name(raw) + if normalized is None: + return Error(CredError.of_misconfigured(f"invalid upstream header name: {raw!r}")) + return Ok(normalized) + + +class HeaderCarrier(BaseModel): + """Where a resolved credential is written upstream, and how its value is formatted. + + ``Authorization: Bearer`` is only OAuth's *default* conveyance (RFC 6750 section 2.1), not its + only one: an ESB or API gateway commonly terminates its own credential in a private header while + a second credential passes through to the origin, so a credential has to be able to say which + slot it owns. Modeled like OpenAPI's apiKey scheme, so any upstream convention is expressible + (Authorization + Bearer, a raw value on X-API-Key, Ocp-Apim-Subscription-Key, esb-oauth, ...). + + Every config whose credential the gateway mints or holds inherits this, so no resolver arm names + a header itself and the conflict rule in ``_resolve_v2_auth`` can always ask the auth object + which slot it is about to occupy. ``passthrough`` deliberately does not: it forwards the + caller's own credential into the slot the caller used, and mints nothing to place. + """ + + model_config = ConfigDict(frozen=True) + header_name: str = DEFAULT_CREDENTIAL_HEADER + value_prefix: str = "Bearer" + + @field_validator("header_name") + @classmethod + def _check_header_name(cls, value: str) -> str: + match validate_header_name(value): + case Ok(name): + return name + case Error(err): + raise ValueError(err.summary) + + def header(self, value: str) -> tuple[str, str]: + formatted: Final = f"{self.value_prefix} {value}" if self.value_prefix else value + return self.header_name, formatted + + +class AuthorizationCodeConfig(HeaderCarrier): """Per-user 3LO; the gateway is the OAuth client and stores the user's token. Endpoints are discovered (RFC 9728 -> RFC 8414) and the client is registered via DCR @@ -179,7 +228,7 @@ class AuthorizationCodeConfig(BaseModel): token_url: str | None = None -class ClientCredentialsConfig(BaseModel): +class ClientCredentialsConfig(HeaderCarrier): """M2M service account; one upstream identity for every user. Fields are optional so the config can be built incomplete: a value may be supplied at @@ -203,7 +252,7 @@ class ClientCredentialsConfig(BaseModel): token_endpoint_auth_method: Literal["client_secret_post", "client_secret_basic"] | None = None -class TokenExchangeConfig(BaseModel): +class TokenExchangeConfig(HeaderCarrier): """OBO: swap the caller's live inbound token for a token bound to the upstream's audience. The gateway authenticates to the exchange endpoint as an OAuth client (`client_id`/`client_secret`); the inbound token is sent only to that endpoint, never to the upstream. @@ -255,7 +304,7 @@ class ClientSecretAuth(BaseModel): ClientAuth = Annotated[PrivateKeyJwtAuth | ClientSecretAuth, Field(discriminator="source")] -class IdJagConfig(BaseModel): +class IdJagConfig(HeaderCarrier): """draft-ietf-oauth-identity-assertion-authz-grant (Okta "AI agent token exchange"). Two legs: leg 1 is an RFC 8693 token exchange at the IdP org AS (`org_token_endpoint`) that @@ -297,23 +346,16 @@ class Byok(BaseModel): ApiKeySource = Annotated[SharedKey | Byok, Field(discriminator="source")] -class ApiKeyConfig(BaseModel): +class ApiKeyConfig(HeaderCarrier): """A fixed credential injected as a header. The value is shared (in config) or seeded - per-user (pulled from the store); `header_name` and `value_prefix` say where and how it is - written, modeled like OpenAPI's apiKey scheme so any upstream convention is expressible - (Authorization + Bearer, a raw value on X-API-Key, Ocp-Apim-Subscription-Key, etc.). + per-user (pulled from the store); the inherited `header_name` and `value_prefix` say where + and how it is written. """ model_config = ConfigDict(frozen=True) kind: Literal[AuthSpecKind.api_key] = AuthSpecKind.api_key - header_name: str = "Authorization" - value_prefix: str = "Bearer" key_source: ApiKeySource - def header(self, value: str) -> tuple[str, str]: - formatted: Final = f"{self.value_prefix} {value}" if self.value_prefix else value - return self.header_name, formatted - class PassthroughConfig(BaseModel): """Client-driven upstream OAuth; the gateway forwards the client's upstream token.""" diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 3c6eb06bc71..d9414e9ffb7 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -432,7 +432,6 @@ if MCP_AVAILABLE: _client_forwarded_authorization_headers, _resolve_openapi_tool_auth, _should_strip_caller_authorization, - _without_authorization, global_mcp_server_manager, ) from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import ( @@ -451,6 +450,7 @@ if MCP_AVAILABLE: split_server_prefix_from_name, strip_known_server_prefix, ) + from litellm.types.mcp import DEFAULT_CREDENTIAL_HEADER, without_header ###################################################### ############ MCP Tools List REST API Response Object # @@ -1732,7 +1732,7 @@ if MCP_AVAILABLE: raw_headers=raw_headers, user_api_key_auth=user_api_key_auth, ): - extra_headers = _without_authorization(extra_headers) + extra_headers = without_header(extra_headers, DEFAULT_CREDENTIAL_HEADER) elif is_client_forwarded_mode: if not withhold_forwarded_authorization: extra_headers = _client_forwarded_authorization_headers( diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index 54a591a5e1a..556a30d0b29 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -204,6 +204,7 @@ if MCP_AVAILABLE: MCP_ADMIN_CONFIG_CREDENTIAL_KEYS, MCPAuth, MCPCredentials, + normalize_upstream_header_name, ) from litellm.types.mcp_server.mcp_server_manager import MCPServer @@ -239,9 +240,26 @@ if MCP_AVAILABLE: detail={"error": error_messages_text}, ) + def _validate_upstream_token_header(payload: McpServerPayloadLike) -> None: + credentials: Final = getattr(payload, "credentials", None) + raw: Final = credentials.get("upstream_token_header") if isinstance(credentials, dict) else None + if not isinstance(raw, str) or raw == "": + return + if normalize_upstream_header_name(raw) is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": ( + f"Invalid upstream_token_header {raw!r}: must be a valid HTTP header name " + "(RFC 7230 token, e.g. 'esb-oauth')" + ) + }, + ) + def validate_and_normalize_mcp_server_payload(payload: McpServerPayloadLike) -> None: _base_validate_and_normalize_mcp_server_payload(payload) _validate_mcp_server_name_fields(payload) + _validate_upstream_token_header(payload) def stamp_omitted_oauth2_flow(payload: NewMCPServerRequest) -> None: """Fallback only: fill in oauth2_flow when an oauth2 create omits it. @@ -739,6 +757,7 @@ if MCP_AVAILABLE: ("aws_region_name", "aws_region_name"), ("aws_service_name", "aws_service_name"), ("upstream_resource", "upstream_resource"), + ("upstream_token_header", "upstream_token_header"), ) def _has_non_admin_config_credentials(credentials: "MCPCredentials | None") -> bool: diff --git a/litellm/types/mcp.py b/litellm/types/mcp.py index 57437ea7e54..1b8baf2da09 100644 --- a/litellm/types/mcp.py +++ b/litellm/types/mcp.py @@ -1,6 +1,11 @@ import enum +import re +from collections.abc import Awaitable, Callable, Mapping +from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, Literal +from urllib.parse import urlsplit +import httpx from pydantic import BaseModel from typing_extensions import TypedDict @@ -181,6 +186,15 @@ class MCPCredentials(TypedDict, total=False): ``audience``, which is the RFC 8693 token-exchange parameter. """ + upstream_token_header: str | None # writable-ok: pydantic warns it cannot honour ReadOnly here + """ + Which upstream header carries the credential LiteLLM resolves for this server. Omitted when + unset, which keeps RFC 6750's default of ``Authorization``. Set it when the upstream expects the + gateway's token somewhere else (an ESB terminating its own credential on e.g. ``esb-oauth``), so + a separate operator-configured ``Authorization`` reaches the origin untouched. Non-secret, so it + is stored in plaintext and returned on admin reads. + """ + client_private_key: str | None """ PEM private key used to sign the private-key-JWT client_assertion (RFC 7523) @@ -223,7 +237,92 @@ class MCPCredentials(TypedDict, total=False): """ -MCP_ADMIN_CONFIG_CREDENTIAL_KEYS: Final[tuple[str, ...]] = ("upstream_resource",) +DEFAULT_CREDENTIAL_HEADER: Final = "Authorization" + +_HEADER_NAME_TOKEN: Final = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") + + +def normalize_upstream_header_name(raw: str) -> str | None: + """The trimmed header name if it is a usable RFC 7230 ``token``, else None. + + One owner for the grammar; each caller picks its own failure shape (a config-load raise, an + API 400, a typed CredError). An operator-supplied name reaches egress verbatim, so a value + carrying CR/LF, spaces or separators must never get that far. + """ + stripped: Final = raw.strip() + return stripped if stripped and _HEADER_NAME_TOKEN.match(stripped) else None + + +def same_header(name: str, other: str) -> bool: + """Whether two HTTP header names are the same one. They are case-insensitive (RFC 7230 3.2).""" + return name.lower() == other.lower() + + +def has_header(headers: Mapping[str, str] | None, name: str) -> bool: + """Whether ``headers`` carries ``name`` under any casing.""" + return bool(headers) and any(same_header(key, name) for key in headers or {}) + + +def without_header(headers: Mapping[str, str] | None, name: str) -> dict[str, str] | None: + """A copy of ``headers`` with every casing of ``name`` removed, or None if nothing remains. + + The one owner of "drop this credential's header". Both MCP stacks and the upstream-credential + resolver share it so a slot can never be dropped case-sensitively in one place and + case-insensitively in another, which is how an injected header came to shadow a resolved + credential on the v1 path. + """ + if not headers: + return None + filtered: Final = {key: value for key, value in headers.items() if not same_header(key, name)} + return filtered or None + + +_DEFAULT_PORTS: Final[Mapping[str, int]] = MappingProxyType({"http": 80, "https": 443}) + + +def crosses_origin(configured: str, target: str) -> bool: + """Whether ``target`` leaves ``configured``'s origin, by the rule HTTP clients use. + + Origin is scheme, host and port, not host alone, so a same-host HTTPS downgrade or a port change + counts as crossing it. A plain http -> https upgrade of the same host is exempt, matching what + httpx exempts when it decides whether to keep ``Authorization`` across a redirect. + """ + a: Final = urlsplit(configured) + b: Final = urlsplit(target) + port_a: Final = a.port or _DEFAULT_PORTS.get(a.scheme) + port_b: Final = b.port or _DEFAULT_PORTS.get(b.scheme) + if a.scheme == b.scheme and a.hostname == b.hostname and port_a == port_b: + return False + return not ( + a.hostname == b.hostname and a.scheme == "http" and port_a == 80 and b.scheme == "https" and port_b == 443 + ) + + +def custom_credential_slot(headers: Mapping[str, str] | None) -> str | None: + """The first header carrying a credential somewhere other than ``Authorization``, if any.""" + return next((name for name in headers or {} if not same_header(name, DEFAULT_CREDENTIAL_HEADER)), None) + + +def credential_redirect_hook( + configured_url: str, slot: str | None +) -> Callable[[httpx.Request], Awaitable[None]] | None: + """An httpx request hook dropping ``slot`` once a redirect leaves ``configured_url``'s origin. + + None when no guard is needed, so callers do not each repeat the exemption: HTTP clients already + strip ``Authorization`` across origins, but forward every other header, so only a credential an + operator moved to its own slot can be replayed to whatever host the upstream redirects to. + """ + if not configured_url or not slot or same_header(slot, DEFAULT_CREDENTIAL_HEADER): + return None + + async def guard(request: httpx.Request) -> None: + if slot in request.headers and crosses_origin(configured_url, str(request.url)): + del request.headers[slot] + + return guard + + +MCP_ADMIN_CONFIG_CREDENTIAL_KEYS: Final[tuple[str, ...]] = ("upstream_resource", "upstream_token_header") """Non-secret credential keys returned on read so the admin form can show and clear them. Mirrors ``ADMIN_CONFIG_CREDENTIAL_KEYS`` in ``ui/litellm-dashboard/src/components/mcp_tools/types.tsx``.""" diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index 401793a79e4..9bf3acc601c 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Any, Final, Literal -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, field_validator from litellm.types.mcp import ( DEFAULT_SUBJECT_TOKEN_TYPE, @@ -9,6 +9,7 @@ from litellm.types.mcp import ( MCPAuthType, MCPTokenEndpointAuthMethod, MCPTransportType, + normalize_upstream_header_name, ) # MCPInfo now allows arbitrary additional fields for custom metadata @@ -86,6 +87,22 @@ class MCPServer(BaseModel): # today's behavior; "auto" derives the canonical URI from ``url``; any other value is sent # verbatim. Resolved by ``oauth_utils.resolve_upstream_resource``. upstream_resource: str | None = None + # Which upstream header carries the credential LiteLLM resolves for this server (the minted + # OAuth token, or the static key). None keeps RFC 6750's default, ``Authorization``. An ESB or + # API gateway that terminates its own credential in a private header needs this so a second, + # operator-configured ``Authorization`` can pass through to the origin untouched. + upstream_token_header: str | None = None + + @field_validator("upstream_token_header") + @classmethod + def _check_upstream_token_header(cls, value: str | None) -> str | None: + if value is None or not value.strip(): + return None + normalized: Final = normalize_upstream_header_name(value) + if normalized is None: + raise ValueError(f"upstream_token_header must be a valid HTTP header name (RFC 7230 token), got {value!r}") + return normalized + # AWS SigV4 fields aws_access_key_id: str | None = None aws_secret_access_key: str | None = None diff --git a/tests/test_litellm/experimental_mcp_client/test_mcp_client.py b/tests/test_litellm/experimental_mcp_client/test_mcp_client.py index b1182dd7262..fd7ab3afdab 100644 --- a/tests/test_litellm/experimental_mcp_client/test_mcp_client.py +++ b/tests/test_litellm/experimental_mcp_client/test_mcp_client.py @@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import anyio import httpx import pytest +from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import StaticHeaderAuth from mcp import McpError from mcp.shared.message import SessionMessage from mcp.types import ( @@ -1095,3 +1096,188 @@ def test_mcp_extra_matches_proxy_extra_and_supports_streamable_http(): specifier = Requirement(mcp_extra[0]).specifier assert not specifier.contains("1.23.0") assert specifier.contains("1.28.1") + + +@pytest.mark.parametrize( + "auth_type, default_header", + [ + (MCPAuth.oauth2, "Authorization"), + (MCPAuth.bearer_token, "Authorization"), + (MCPAuth.api_key, "X-API-Key"), + ], +) +def test_v1_auth_headers_default_to_the_auth_type_slot(auth_type: MCPAuth, default_header: str) -> None: + client = MCPClient(server_url="http://up.example.com/mcp", auth_type=auth_type) + client.update_auth_value("tok") + assert default_header in client._get_auth_headers() + + +@pytest.mark.parametrize("auth_type", [MCPAuth.oauth2, MCPAuth.bearer_token, MCPAuth.api_key]) +def test_v1_auth_headers_honor_the_configured_slot(auth_type: MCPAuth) -> None: + """The v1 stack mints its own client_credentials token (oauth2_token_cache) and writes it here, + so leaving this table hardcoded makes the knob a silent no-op for every server that resolves + through v1 rather than the v2 resolver.""" + client = MCPClient( + server_url="http://up.example.com/mcp", + auth_type=auth_type, + auth_header_name="esb-oauth", + ) + client.update_auth_value("tok") + headers = client._get_auth_headers() + assert "esb-oauth" in headers + assert "Authorization" not in headers + assert "X-API-Key" not in headers + + +def test_v1_static_headers_still_win_their_own_slot(): + # extra_headers (which carries static_headers) is applied last on the v1 path, so a static + # Authorization survives untouched while the resolved credential sits on its own header. + client = MCPClient( + server_url="http://up.example.com/mcp", + auth_type=MCPAuth.oauth2, + auth_header_name="esb-oauth", + extra_headers={"Authorization": "Bearer static-upstream-mcp-token"}, + ) + client.update_auth_value("minted") + headers = client._get_auth_headers() + assert headers["esb-oauth"] == "Bearer minted" + assert headers["Authorization"] == "Bearer static-upstream-mcp-token" + + +@pytest.mark.asyncio +async def test_a_custom_credential_header_is_stripped_when_a_redirect_crosses_origin(): + """httpx drops Authorization across origins but keeps every other header, so a credential the + operator moved to its own slot would be replayed to whatever host the upstream redirects to. + Verified against real httpx redirect handling, not a hand-built request. + """ + seen: "list[tuple[str, str]]" = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append((request.url.host, request.headers.get("esb-oauth", ""))) + if request.url.host == "upstream.example.com": + return httpx.Response(302, headers={"Location": "https://attacker.example.com/collect"}) + return httpx.Response(200) + + client = MCPClient( + server_url="https://upstream.example.com/mcp", + auth_type=MCPAuth.oauth2, + auth_header_name="esb-oauth", + ) + client.update_auth_value("minted-token") + factory = client._create_httpx_client_factory() + async with factory(headers=client._get_auth_headers(), timeout=None) as http_client: + http_client._transport = httpx.MockTransport(handler) + await http_client.get("https://upstream.example.com/mcp") + + assert seen[0] == ("upstream.example.com", "Bearer minted-token") + assert seen[1] == ("attacker.example.com", "") + + +@pytest.mark.asyncio +async def test_authorization_is_left_to_httpx_and_needs_no_guard(): + # The default slot is already protected by httpx, so the client must not install a guard for it + # and must not interfere with the ordinary Authorization path. + url = "https://upstream.example.com/mcp" + from litellm.types.mcp import credential_redirect_hook + + def guard_for(client: MCPClient): + return credential_redirect_hook(client.server_url, client._credential_slot) + + assert guard_for(MCPClient(server_url=url, auth_type=MCPAuth.oauth2)) is None + assert guard_for(MCPClient(server_url=url, resolved_auth=StaticHeaderAuth("Bearer x"))) is None + # a v2 resolver slot is discovered from the auth object, without the caller naming it again + custom = MCPClient(server_url=url, resolved_auth=StaticHeaderAuth("Bearer x", header_name="esb-oauth")) + assert guard_for(custom) is not None + # and the same answer arrives via the v1 configured slot + assert guard_for(MCPClient(server_url=url, auth_header_name="ESB-OAuth")) is not None + + +def test_an_injected_header_cannot_shadow_the_configured_credential_slot(): + """The v2 path drops a colliding injected header so the resolved credential wins its slot. The + v1 path applies extra_headers last, so without this it silently sends the injected value and the + upstream rejects a credential the gateway thought it had sent. + """ + client = MCPClient( + server_url="https://upstream.example.com/mcp", + auth_type=MCPAuth.oauth2, + auth_header_name="esb-oauth", + extra_headers={"esb-oauth": "Bearer injected", "X-Trace": "keep"}, + ) + client.update_auth_value("minted-token") + headers = client._get_auth_headers() + assert headers["esb-oauth"] == "Bearer minted-token" + assert headers["X-Trace"] == "keep" + + +def test_without_a_configured_slot_the_existing_precedence_is_unchanged(): + # extra_headers winning over authentication_token is long-standing v1 behavior; the fix above + # must apply only to the slot the operator explicitly named. + client = MCPClient( + server_url="https://upstream.example.com/mcp", + auth_type=MCPAuth.oauth2, + extra_headers={"Authorization": "Bearer injected"}, + ) + client.update_auth_value("minted-token") + assert client._get_auth_headers()["Authorization"] == "Bearer injected" + + +_REDIRECT_CASES = [ + ("https://upstream.example.com/mcp", "https://upstream.example.com/other"), # same origin + ("https://upstream.example.com/mcp", "https://upstream.example.com:443/other"), # explicit default port + ("https://upstream.example.com/mcp", "https://attacker.example.com/collect"), # different host + ("https://upstream.example.com/mcp", "http://upstream.example.com/collect"), # scheme downgrade + ("https://upstream.example.com/mcp", "https://upstream.example.com:8443/other"), # different port + ("https://upstream.example.com/mcp", "https://sub.upstream.example.com/x"), # different host + ("http://upstream.example.com/mcp", "https://upstream.example.com/other"), # http -> https upgrade + ("http://upstream.example.com/mcp", "http://upstream.example.com/other"), # same origin, plain http +] + + +@pytest.mark.parametrize("start,target", _REDIRECT_CASES) +@pytest.mark.asyncio +async def test_the_guard_agrees_with_httpx_about_authorization(start: str, target: str) -> None: + """Our custom slot must be dropped on exactly the redirects where httpx drops Authorization. + + The rule is mirrored rather than imported, so this drives real httpx and compares the two + outcomes. A future httpx that changes its redirect rule reds here instead of silently leaving + the custom slot forwarded where Authorization is not (or stripped where it is not needed). + """ + seen: "list[tuple[str, str, str]]" = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append( + ( + str(request.url), + request.headers.get("authorization", ""), + request.headers.get("esb-oauth", ""), + ) + ) + if str(request.url) == start: + return httpx.Response(302, headers={"Location": target}) + return httpx.Response(200) + + client = MCPClient(server_url=start, auth_type=MCPAuth.oauth2, auth_header_name="esb-oauth") + factory = client._create_httpx_client_factory() + async with factory(headers={"Authorization": "Bearer AUTH", "esb-oauth": "Bearer ESB"}, timeout=None) as http: + http._transport = httpx.MockTransport(handler) + await http.get(start) + + _url, authorization, esb = seen[-1] + assert (authorization == "") == (esb == ""), ( + f"httpx and the guard disagree for {target}: authorization={authorization!r} esb-oauth={esb!r}" + ) + + +def test_a_differently_cased_injected_header_cannot_shadow_the_slot() -> None: + # HTTP header names are case-insensitive and v2 drops the collision case-insensitively, so an + # exact-key check here would leave both spellings in the dict and let the injected value win. + client = MCPClient( + server_url="https://upstream.example.com/mcp", + auth_type=MCPAuth.oauth2, + auth_header_name="esb-oauth", + extra_headers={"ESB-OAuth": "Bearer injected", "X-Trace": "keep"}, + ) + client.update_auth_value("minted-token") + headers = client._get_auth_headers() + assert [v for k, v in headers.items() if k.lower() == "esb-oauth"] == ["Bearer minted-token"] + assert headers["X-Trace"] == "keep" diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py index 0020dbf8d61..c667db7f07c 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py @@ -10,6 +10,7 @@ from types import SimpleNamespace import pytest from fastapi import HTTPException +from pydantic import ValidationError from litellm.proxy._experimental.mcp_server.outbound_credentials.adapter import ( oauth_protected_resource_path, @@ -598,3 +599,92 @@ def test_client_credentials_uses_admin_entered_token_url_when_issuer_yield_empti assert spec is not None assert isinstance(spec.config, ClientCredentialsConfig) assert spec.config.token_url == "https://idp.example.com/token" + + +_M2M_FIELDS = dict( + auth_type=MCPAuth.oauth2, + oauth2_flow="client_credentials", + client_id="cid", + client_secret="csec", + token_url="https://idp.example.com/token", +) +_OBO_FIELDS = dict( + auth_type=MCPAuth.oauth2_token_exchange, + client_id="cid", + client_secret="csec", + token_exchange_endpoint="https://idp.example.com/token", +) +_ID_JAG_FIELDS = dict( + auth_type=MCPAuth.oauth2_id_jag, + client_id="cid", + client_secret="csec", + token_exchange_endpoint="https://idp.example.com/token", + id_jag_resource_token_endpoint="https://mcp-as.example.com/token", + audience="api://mcp", +) +_AUTHZ_CODE_FIELDS = dict(auth_type=MCPAuth.oauth2, url="https://up.example.com/mcp") +_STATIC_FIELDS = dict(auth_type=MCPAuth.bearer_token, authentication_token="static-tok") + +_ARM_FIELDS = ( + ("client_credentials", _M2M_FIELDS), + ("token_exchange", _OBO_FIELDS), + ("id_jag", _ID_JAG_FIELDS), + ("authorization_code", _AUTHZ_CODE_FIELDS), + ("api_key", _STATIC_FIELDS), +) + + +@pytest.mark.parametrize("name,fields", _ARM_FIELDS, ids=[n for n, _ in _ARM_FIELDS]) +def test_upstream_token_header_reaches_every_arms_config(name, fields): + # to_server_spec builds each arm's config from a hand-written kwargs list, so an arm that + # forgets to read the field fails silently: the server keeps writing to Authorization. + spec = to_server_spec(_server(upstream_token_header="esb-oauth", **fields)) + assert spec is not None + assert spec.config.header_name == "esb-oauth" + + +@pytest.mark.parametrize("name,fields", _ARM_FIELDS, ids=[n for n, _ in _ARM_FIELDS]) +def test_omitting_the_field_keeps_each_arms_shipped_default(name, fields): + spec = to_server_spec(_server(**fields)) + assert spec is not None + assert spec.config.header_name == "Authorization" + + +def test_api_key_scheme_default_survives_when_the_field_is_unset(): + spec = to_server_spec(_server(auth_type=MCPAuth.api_key, authentication_token="k")) + assert spec is not None + assert spec.config.header_name == "X-API-Key" + assert spec.config.value_prefix == "" + + +def test_the_field_overrides_the_api_key_scheme_default(): + spec = to_server_spec(_server(auth_type=MCPAuth.api_key, authentication_token="k", upstream_token_header="X-Esb")) + assert spec is not None + assert spec.config.header_name == "X-Esb" + + +@pytest.mark.parametrize("bad", ["with space", "has:colon", "trailing\r\nX-Injected", 'quoted"name']) +def test_a_malformed_header_name_is_refused_when_the_server_is_built(bad): + """Validation belongs at ingestion, not at spec building. Raising inside to_server_spec would + abort the whole aggregate tools/list, so one mistyped server would silently empty the tool list + for every other server too. Refusing at MCPServer construction fails the config load loudly + instead, and means no malformed value can ever reach an arm. + """ + with pytest.raises(ValidationError): + _server(upstream_token_header=bad, **_M2M_FIELDS) + + +def test_a_valid_header_name_is_trimmed_at_ingestion(): + assert _server(upstream_token_header=" esb-oauth ", **_M2M_FIELDS).upstream_token_header == "esb-oauth" + + +@pytest.mark.parametrize("blank", ["", " ", "\t"]) +def test_a_blank_header_name_means_unset_rather_than_an_error(blank): + """The management API treats a blank as "not supplied" and stores it, so raising here made every + later rebuild of that server 500 instead of falling back to the default Authorization behavior. + """ + server = _server(upstream_token_header=blank, **_M2M_FIELDS) + assert server.upstream_token_header is None + spec = to_server_spec(server) + assert spec is not None + assert spec.config.header_name == "Authorization" diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_client_credentials.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_client_credentials.py index a5d17428b37..010e7e14d39 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_client_credentials.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_client_credentials.py @@ -341,7 +341,7 @@ async def test_bearer_auth_sends_the_token_and_leaves_a_success_alone(): async def refetch(failed: str) -> "str | None": raise AssertionError("must not refetch on success") - auth = ClientCredentialsBearerAuth("m2m-token", refetch) + auth = ClientCredentialsBearerAuth("m2m-token", refetch, ClientCredentialsConfig()) async with httpx.AsyncClient(transport=transport, auth=auth) as client: response = await client.get("https://upstream.example.com/mcp") assert response.status_code == 200 @@ -357,7 +357,7 @@ async def test_bearer_auth_retries_a_401_once_with_a_fresh_token(): refetched.append(failed) return "fresh-token" - auth = ClientCredentialsBearerAuth("stale-token", refetch) + auth = ClientCredentialsBearerAuth("stale-token", refetch, ClientCredentialsConfig()) async with httpx.AsyncClient(transport=transport, auth=auth) as client: response = await client.get("https://upstream.example.com/mcp") assert response.status_code == 200 @@ -377,7 +377,7 @@ async def test_bearer_auth_remembers_the_rotated_token_for_later_requests(): refetched.append(failed) return "fresh-token" - auth = ClientCredentialsBearerAuth("stale-token", refetch) + auth = ClientCredentialsBearerAuth("stale-token", refetch, ClientCredentialsConfig()) async with httpx.AsyncClient(transport=transport, auth=auth) as client: first = await client.get("https://upstream.example.com/mcp") second = await client.get("https://upstream.example.com/mcp") @@ -393,7 +393,7 @@ async def test_bearer_auth_surfaces_the_401_when_the_refetch_fails(): async def refetch(failed: str) -> "str | None": return None - auth = ClientCredentialsBearerAuth("stale-token", refetch) + auth = ClientCredentialsBearerAuth("stale-token", refetch, ClientCredentialsConfig()) async with httpx.AsyncClient(transport=transport, auth=auth) as client: response = await client.get("https://upstream.example.com/mcp") assert response.status_code == 401 @@ -409,7 +409,7 @@ async def test_bearer_auth_gives_up_after_a_second_401(): refetched.append(failed) return "fresh-token" - auth = ClientCredentialsBearerAuth("stale-token", refetch) + auth = ClientCredentialsBearerAuth("stale-token", refetch, ClientCredentialsConfig()) async with httpx.AsyncClient(transport=transport, auth=auth) as client: response = await client.get("https://upstream.example.com/mcp") assert response.status_code == 401 @@ -421,7 +421,60 @@ def test_bearer_auth_rejects_sync_clients(): async def refetch(failed: str) -> "str | None": return None - auth = ClientCredentialsBearerAuth("token", refetch) + auth = ClientCredentialsBearerAuth("token", refetch, ClientCredentialsConfig()) with httpx.Client(transport=httpx.MockTransport(lambda request: httpx.Response(200)), auth=auth) as client: with pytest.raises(RuntimeError): client.get("https://upstream.example.com/mcp") + + +@pytest.mark.asyncio +async def test_bearer_auth_writes_the_minted_token_to_the_configured_header(): + seen: "list[dict[str, str]]" = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(dict(request.headers)) + return httpx.Response(200) + + async def refetch(failed: str) -> "str | None": + raise AssertionError("must not refetch on success") + + auth = ClientCredentialsBearerAuth("m2m-token", refetch, ClientCredentialsConfig(header_name="esb-oauth")) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler), auth=auth) as client: + await client.get("https://upstream.example.com/mcp") + assert seen[0]["esb-oauth"] == "Bearer m2m-token" + assert "authorization" not in seen[0] + + +@pytest.mark.asyncio +async def test_the_401_refetch_retry_also_targets_the_configured_header(): + # The retry is a SECOND write of the credential. Honoring the carrier only on the first write + # would silently send the fresh token to Authorization, so the ESB rejects every recovered + # request while the first attempt looked correct. + seen: "list[dict[str, str]]" = [] + responses = [httpx.Response(401), httpx.Response(200)] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(dict(request.headers)) + return responses[min(len(seen) - 1, len(responses) - 1)] + + async def refetch(failed: str) -> "str | None": + return "fresh-token" + + auth = ClientCredentialsBearerAuth("stale-token", refetch, ClientCredentialsConfig(header_name="esb-oauth")) + async with httpx.AsyncClient(transport=httpx.MockTransport(handler), auth=auth) as client: + response = await client.get("https://upstream.example.com/mcp") + assert response.status_code == 200 + assert [h["esb-oauth"] for h in seen] == ["Bearer stale-token", "Bearer fresh-token"] + assert all("authorization" not in h for h in seen) + + +@pytest.mark.asyncio +async def test_bearer_auth_advertises_the_header_it_will_occupy(): + # _resolve_v2_auth reads header_name off the auth object to decide which injected header + # conflicts; an auth object that lies about its slot would drop the wrong one. + async def refetch(failed: str) -> "str | None": + return None + + assert ClientCredentialsBearerAuth("t", refetch, ClientCredentialsConfig()).header_name == "Authorization" + default_carrier = ClientCredentialsConfig(header_name="esb-oauth") + assert ClientCredentialsBearerAuth("t", refetch, default_carrier).header_name == "esb-oauth" diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py index 0d130767bd5..9d63e8c2c1c 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py @@ -1033,3 +1033,70 @@ async def test_invalidate_credentials_for_id_jag_is_a_noop_without_a_caller_toke assert isinstance(first, Ok) and isinstance(second, Ok) assert _emitted(second.ok)["Authorization"] == "Bearer cached-bearer" assert len(endpoint.calls) == 2 + + +async def _resolve_with_carrier(kind: str, header: str): + """Resolve one minted-token arm whose config targets ``header``.""" + if kind == "client_credentials": + source = _FakeM2MSource(Ok(OAuthToken(access_token="minted"))) + config = _M2M.model_copy(update={"header_name": header}) + provider = UpstreamCredentialProvider(client_credentials_source=source) + return await provider.resolve_credentials(_SUBJECT, _spec(config)) + if kind == "token_exchange": + exchanger = _FakeExchanger(Ok(OAuthToken(access_token="minted"))) + config = _OBO.model_copy(update={"header_name": header}) + subject = Subject(tenant_id="acme", subject_id="alice", inbound_token=SecretStr("caller-jwt")) + provider = UpstreamCredentialProvider(token_exchanger=exchanger) + return await provider.resolve_credentials(subject, _spec(config)) + if kind == "authorization_code": + store = _FakeTokenStore({("alice", "s"): OAuthToken(access_token="minted")}) + provider = UpstreamCredentialProvider(oauth_token_store=store) + return await provider.resolve_credentials( + Subject(tenant_id="", subject_id="alice"), + _spec(AuthorizationCodeConfig(header_name=header)), + ) + endpoint = _FakeTokenEndpoint( + [ + Ok(ExchangedToken(access_token="id-jag-assertion", expires_in=300)), + Ok(ExchangedToken(access_token="minted", expires_in=300)), + ] + ) + config = _id_jag_config().model_copy(update={"header_name": header}) + subject = Subject(tenant_id="acme", subject_id="alice", inbound_token=SecretStr("caller-id-token")) + provider = UpstreamCredentialProvider(token_endpoint=endpoint) + return await provider.resolve_credentials(subject, _spec(config)) + + +_MINTED_ARMS = ("client_credentials", "token_exchange", "authorization_code", "id_jag") + + +@pytest.mark.parametrize("kind", _MINTED_ARMS) +@pytest.mark.asyncio +async def test_every_minted_arm_emits_its_configured_header(kind): + # One arm left on a hardcoded Authorization is a silent no-op for exactly the server that + # configured the knob, so this is asserted across all four rather than on the M2M arm alone. + result = await _resolve_with_carrier(kind, "esb-oauth") + assert isinstance(result, Ok) + headers, _ = await _emitted_async(result.ok) + assert headers["esb-oauth"] == "Bearer minted" + assert "authorization" not in headers + + +@pytest.mark.parametrize("kind", _MINTED_ARMS) +@pytest.mark.asyncio +async def test_every_minted_arm_still_defaults_to_authorization(kind): + result = await _resolve_with_carrier(kind, "Authorization") + assert isinstance(result, Ok) + headers, _ = await _emitted_async(result.ok) + assert headers["Authorization"] == "Bearer minted" + + +@pytest.mark.asyncio +async def test_passthrough_ignores_the_carrier_and_keeps_the_callers_slot(): + # Passthrough mints nothing: it forwards the caller's own credential, so it has no carrier to + # configure and must keep using the header the caller aimed it at. + subject = Subject(tenant_id="", subject_id="", inbound_token=SecretStr("caller-token")) + result = await UpstreamCredentialProvider().resolve_credentials(subject, _spec(PassthroughConfig())) + assert isinstance(result, Ok) + headers, _ = await _emitted_async(result.ok) + assert headers["Authorization"] == "caller-token" diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_types.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_types.py index bb25ab6bd3c..d4b51b08e06 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_types.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_types.py @@ -14,9 +14,11 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials import ( Ambient, ApiKeyConfig, AuthConfig, + AuthorizationCodeConfig, AuthSpecKind, AwsSigV4Config, Byok, + ClientCredentialsConfig, ClientSecretAuth, CredError, Error, @@ -27,7 +29,9 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials import ( ServerSpec, SharedKey, StaticKeys, + TokenExchangeConfig, parse_auth_spec_kind, + validate_header_name, ) _AUTH_CONFIG = TypeAdapter(AuthConfig) @@ -229,3 +233,61 @@ def test_id_jag_server_spec_derives_auth_spec_kind(): config=config, ) assert spec.auth_spec_kind is AuthSpecKind.id_jag + + +_CARRIER_CONFIGS = ( + ("client_credentials", ClientCredentialsConfig), + ("token_exchange", lambda **kw: TokenExchangeConfig(token_exchange_endpoint="https://idp/te", **kw)), + ("authorization_code", AuthorizationCodeConfig), + ( + "id_jag", + lambda **kw: IdJagConfig( + org_token_endpoint="https://idp.example.com/token", + resource_token_endpoint="https://mcp-as.example.com/token", + client_id="litellm", + client_auth=ClientSecretAuth(client_secret=SecretStr("s")), + **kw, + ), + ), + ("api_key", lambda **kw: ApiKeyConfig(key_source=SharedKey(value=SecretStr("k")), **kw)), +) + + +@pytest.mark.parametrize("name,build", _CARRIER_CONFIGS, ids=[n for n, _ in _CARRIER_CONFIGS]) +def test_every_resolved_credential_config_defaults_to_rfc6750_authorization(name, build): + # The default is what preserves today's wire behavior for every existing server. + assert build().header("tok") == ("Authorization", "Bearer tok") + + +@pytest.mark.parametrize("name,build", _CARRIER_CONFIGS, ids=[n for n, _ in _CARRIER_CONFIGS]) +def test_every_resolved_credential_config_honors_a_custom_header(name, build): + assert build(header_name="esb-oauth").header("tok") == ("esb-oauth", "Bearer tok") + + +@pytest.mark.parametrize("name,build", _CARRIER_CONFIGS, ids=[n for n, _ in _CARRIER_CONFIGS]) +def test_every_resolved_credential_config_can_send_a_raw_value(name, build): + assert build(header_name="esb-oauth", value_prefix="").header("tok") == ("esb-oauth", "tok") + + +@pytest.mark.parametrize( + "bad", + [ + "with space", + "has:colon", + "trailing\r\nX-Injected", + "", + " ", + "quoted\"name", + ], +) +def test_header_name_outside_the_rfc7230_token_grammar_is_rejected(bad): + # An operator-supplied name reaches egress verbatim, so anything that could split a + # header must fail closed at construction rather than be sanitized later. + with pytest.raises(ValidationError): + ClientCredentialsConfig(header_name=bad) + assert isinstance(validate_header_name(bad), Error) + + +def test_header_name_is_trimmed_by_the_one_validator(): + assert validate_header_name(" esb-oauth ") == Ok("esb-oauth") + assert ClientCredentialsConfig(header_name=" esb-oauth ").header_name == "esb-oauth" 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 cdea803ebf3..5508259273d 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 @@ -43,7 +43,6 @@ from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( _obo_retry_applies, _resolve_openapi_tool_auth, _should_strip_caller_authorization, - _without_authorization, ) from litellm.proxy._types import ( LiteLLM_MCPServerTable, @@ -2405,6 +2404,104 @@ class TestMCPServerManager: assert client._resolved_auth is not None assert "authorization" not in {k.lower() for k in (client.extra_headers or {})} + @staticmethod + def _esb_server(header: "str | None") -> MCPServer: + return MCPServer( + server_id="esb", + name="esb-server", + url="https://up.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + oauth2_flow="client_credentials", + client_id="cid", + client_secret="csec", + token_url="https://idp.example.com/token", + upstream_token_header=header, + static_headers={"Authorization": "Bearer static-upstream-mcp-token"}, + ) + + @pytest.mark.asyncio + async def test_static_authorization_survives_a_minted_token_aimed_elsewhere(self): + """The dual-credential case: an ESB wants the gateway-minted token on its own header while a + separate static Authorization passes through to the origin. Dropping Authorization here (the + old name-blind behavior) deletes the second credential and the upstream 401s.""" + from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import ( + StaticHeaderAuth, + ) + from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Ok + + class _FakeProvider: + async def resolve_credentials(self, subject, server): + return Ok(StaticHeaderAuth("Bearer MINTED-M2M", header_name="esb-oauth")) + + manager = MCPServerManager(cred_provider=_FakeProvider()) + client = await manager._create_mcp_client( + self._esb_server("esb-oauth"), + extra_headers={"Authorization": "Bearer static-upstream-mcp-token"}, + ) + + assert client._resolved_auth is not None + assert (client.extra_headers or {})["Authorization"] == "Bearer static-upstream-mcp-token" + + @pytest.mark.asyncio + async def test_a_minted_token_aimed_at_the_static_header_still_wins_that_slot(self): + """The negative class of the test above: when the two DO collide the resolver-owned + credential is still authoritative, so the knob cannot be used to smuggle a second + credential into the same slot.""" + from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import ( + StaticHeaderAuth, + ) + from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Ok + + class _FakeProvider: + async def resolve_credentials(self, subject, server): + return Ok(StaticHeaderAuth("Bearer MINTED-M2M", header_name="esb-oauth")) + + manager = MCPServerManager(cred_provider=_FakeProvider()) + client = await manager._create_mcp_client( + self._esb_server("esb-oauth"), + extra_headers={"esb-oauth": "Bearer signer-jwt", "X-Trace": "keep-me"}, + ) + + assert client._resolved_auth is not None + assert "esb-oauth" not in {k.lower() for k in (client.extra_headers or {})} + assert (client.extra_headers or {})["X-Trace"] == "keep-me" + + @pytest.mark.asyncio + async def test_a_differently_cased_injected_header_is_still_recognised_as_the_collision(self): + """HTTP header names are case-insensitive, so the conflict check must be too. + + A case-sensitive check reports no conflict and hands the injected header back untouched, so + the returned extra_headers still carries a second copy of the credential slot for every + downstream consumer of that dict. httpx happens to collapse the two on the wire, which is + exactly why this needs pinning rather than being left to luck. + """ + from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import ( + StaticHeaderAuth, + ) + from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Ok + + class _FakeProvider: + async def resolve_credentials(self, subject, server): + return Ok(StaticHeaderAuth("Bearer MINTED", header_name="esb-oauth")) + + manager = MCPServerManager(cred_provider=_FakeProvider()) + client = await manager._create_mcp_client( + self._esb_server("esb-oauth"), + extra_headers={"ESB-OAuth": "Bearer injected", "X-Trace": "keep"}, + ) + + assert client._resolved_auth is not None + assert not any(k.lower() == "esb-oauth" for k in (client.extra_headers or {})) + assert (client.extra_headers or {})["X-Trace"] == "keep" + + def test_without_header_drops_only_the_named_header(self): + from litellm.types.mcp import DEFAULT_CREDENTIAL_HEADER, without_header + + headers = {"Authorization": "Bearer a", "esb-oauth": "Bearer b", "X-Trace": "t"} + assert without_header(headers, "ESB-OAuth") == {"Authorization": "Bearer a", "X-Trace": "t"} + assert without_header(headers, DEFAULT_CREDENTIAL_HEADER) == {"esb-oauth": "Bearer b", "X-Trace": "t"} + @pytest.mark.asyncio async def test_preflight_token_exchange_challenges_on_rejected_subject(self): """A subject the IdP rejects must raise the RFC 9728 401 challenge from the preflight, so a @@ -2624,14 +2721,16 @@ class TestMCPServerManager: if captured_extra_headers: assert "authorization" not in {k.lower() for k in captured_extra_headers} - def test_without_authorization_drops_only_the_credential(self): + def test_without_header_drops_only_the_credential(self): + from litellm.types.mcp import without_header + # None / empty -> None - assert _without_authorization(None) is None - assert _without_authorization({}) is None + assert without_header(None, "Authorization") is None + assert without_header({}, "Authorization") is None # Only Authorization present -> nothing left -> None (case-insensitive) - assert _without_authorization({"authorization": "Bearer x"}) is None + assert without_header({"authorization": "Bearer x"}, "Authorization") is None # Authorization dropped, other headers kept - assert _without_authorization({"Authorization": "Bearer x", "X-Trace-Id": "t"}) == {"X-Trace-Id": "t"} + assert without_header({"Authorization": "Bearer x", "X-Trace-Id": "t"}, "Authorization") == {"X-Trace-Id": "t"} @pytest.mark.asyncio async def test_call_regular_mcp_tool_passthrough_forwards_authorization_with_admission_header( @@ -9641,13 +9740,38 @@ class TestMaterializeAuthHeaders: from litellm.proxy._experimental.mcp_server.outbound_credentials.client_credentials import ( ClientCredentialsBearerAuth, ) + from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( + ClientCredentialsConfig, + ) async def _refetch(_stale: str): return None - headers = await _materialize_auth_headers(ClientCredentialsBearerAuth("m2m-token", _refetch)) + default_carrier = ClientCredentialsConfig() + headers = await _materialize_auth_headers(ClientCredentialsBearerAuth("m2m-token", _refetch, default_carrier)) assert headers == {"Authorization": "Bearer m2m-token"} + @pytest.mark.asyncio + async def test_materialize_follows_the_minted_token_to_a_custom_header(self): + # The OpenAPI arm reads header_name off the auth object rather than assuming Authorization, + # so it carries the knob with no per-arm change. This pins that it stays that way. + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + _materialize_auth_headers, + ) + from litellm.proxy._experimental.mcp_server.outbound_credentials.client_credentials import ( + ClientCredentialsBearerAuth, + ) + from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( + ClientCredentialsConfig, + ) + + async def _refetch(_stale: str): + return None + + esb_carrier = ClientCredentialsConfig(header_name="esb-oauth") + headers = await _materialize_auth_headers(ClientCredentialsBearerAuth("m2m-token", _refetch, esb_carrier)) + assert headers == {"esb-oauth": "Bearer m2m-token"} + @pytest.mark.asyncio async def test_noop_and_none_materialize_to_none(self): from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_oauth2_token_cache.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_oauth2_token_cache.py index b1aa16a30c0..f7567efcabc 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_oauth2_token_cache.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_oauth2_token_cache.py @@ -13,6 +13,7 @@ import pytest from litellm.proxy._experimental.mcp_server.oauth2_token_cache import ( MCPOAuth2TokenCache, resolve_mcp_auth, + resolved_token_header, ) from litellm.proxy._types import MCPTransport from litellm.types.mcp import MCPAuth @@ -411,3 +412,50 @@ async def test_m2m_mint_uses_admin_entered_token_url_when_issuer_yield_empties_r assert result == "m2m-token-configured" assert mock_client.post.call_args[0][0] == "https://auth.example.com/token" + + +def _m2m_server(**overrides): + from litellm.types.mcp import MCPAuth, MCPTransport + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + fields = dict( + server_id="s", + name="n", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + oauth2_flow="client_credentials", + client_id="cid", + client_secret="csec", + token_url="https://idp.example.com/token", + ) + fields.update(overrides) + return MCPServer(**fields) + + +def test_resolved_token_header_follows_the_configured_header_for_a_gateway_resolved_token(): + # resolve_mcp_auth mints the M2M token on this branch, so the value is the gateway's own and + # follows upstream_token_header. + assert resolved_token_header(_m2m_server(upstream_token_header="esb-oauth")) == "esb-oauth" + + +def test_resolved_token_header_is_none_when_the_server_configures_nothing(): + assert resolved_token_header(_m2m_server()) is None + + +def test_a_caller_supplied_credential_never_moves(): + # The caller aimed their own token at the slot the upstream normally uses. Relocating it would + # break every existing x-mcp-auth caller on a server that sets the field for its own token. + server = _m2m_server(upstream_token_header="esb-oauth") + assert resolved_token_header(server, "Bearer caller-token") is None + assert resolved_token_header(server, {"Authorization": "Bearer caller-token"}) is None + + +def test_the_header_and_the_value_agree_on_which_branch_they_took(): + # The two helpers are read as a pair at one call site, so they must never disagree about + # whether the credential came from the caller or from the server's own config. + import asyncio + + server = _m2m_server(upstream_token_header="esb-oauth", authentication_token="static-tok") + caller = "Bearer caller-token" + assert asyncio.run(resolve_mcp_auth(server, caller)) == caller + assert resolved_token_header(server, caller) is None diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_tool_auth.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_tool_auth.py index bd953dc55f3..64614c094ba 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_tool_auth.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_tool_auth.py @@ -729,3 +729,121 @@ async def test_local_dispatch_reports_the_outcome_instead_of_success(failure: st # A non-auth upstream failure stays a 200 with isError, so REST does not report it as a gateway 500 assert result.isError is True assert "upstream returned HTTP 429" in result.content[0].text + + +@pytest.mark.parametrize( + "resolved,expect_guard", + [ + ({"esb-oauth": "Bearer minted"}, True), + ({"Authorization": "Bearer minted"}, False), + ({}, False), + ], +) +def test_only_a_custom_credential_slot_needs_the_redirect_guard(resolved, expect_guard): + """The OpenAPI arm sends resolved credentials through a redirect-following client, so a custom + slot needs the same cross-origin guard the MCP client installs. Authorization does not: the HTTP + client already strips that one, and taking the guarded path would give up the shared client. + """ + from litellm.types.mcp import DEFAULT_CREDENTIAL_HEADER, same_header + + guarded = next((n for n in resolved if not same_header(n, DEFAULT_CREDENTIAL_HEADER)), None) + assert (guarded is not None) is expect_guard + + +@pytest.mark.asyncio +async def test_the_openapi_arm_drops_a_custom_slot_across_origins(): + """End to end on the hook the OpenAPI arm installs: same origin keeps the credential, a redirect + to another host does not carry it. + """ + import httpx + + from litellm.types.mcp import credential_redirect_hook + + hook = credential_redirect_hook("https://api.example.com/v1/things", "esb-oauth") + + same = httpx.Request("POST", "https://api.example.com/v1/other", headers={"esb-oauth": "Bearer m"}) + await hook(same) + assert same.headers["esb-oauth"] == "Bearer m" + + foreign = httpx.Request("POST", "https://attacker.example.com/collect", headers={"esb-oauth": "Bearer m"}) + await hook(foreign) + assert "esb-oauth" not in foreign.headers + + +def test_the_openapi_arm_installs_the_guard_when_a_credential_rides_a_custom_slot(): + """Pins the wiring, not just the hook: the arm must actually build a guarded client. Testing the + hook alone passes even if this arm never installs it. + """ + from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import ( + _request_resolved_auth_headers, + _upstream_client, + ) + + token = _request_resolved_auth_headers.set({"esb-oauth": "Bearer minted"}) + try: + client = _upstream_client() + assert client.client.event_hooks["request"], "custom slot must install a redirect guard" + finally: + _request_resolved_auth_headers.reset(token) + + +def test_the_guarded_client_is_reused_rather_than_built_per_call(): + """A fresh handler per guarded call is never closed, so every OpenAPI tool call on a server that + sets upstream_token_header would leak an httpx client and its connection pool. Both variants + have to come from the shared cache. + """ + from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import ( + _request_resolved_auth_headers, + _upstream_client, + ) + + token = _request_resolved_auth_headers.set({"esb-oauth": "Bearer minted"}) + try: + assert _upstream_client() is _upstream_client() + finally: + _request_resolved_auth_headers.reset(token) + + +@pytest.mark.asyncio +async def test_the_shared_guard_reads_the_url_from_the_request_context(): + """The hook is one stable object so the client stays cacheable, which means the origin it guards + against has to arrive per request rather than being closed over. + """ + import httpx + + from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import ( + _drop_credential_across_origin, + _request_resolved_auth_headers, + _request_upstream_url, + ) + + creds = _request_resolved_auth_headers.set({"esb-oauth": "Bearer minted"}) + url = _request_upstream_url.set("https://api.example.com/v1/things") + try: + same = httpx.Request("POST", "https://api.example.com/v1/other", headers={"esb-oauth": "Bearer m"}) + await _drop_credential_across_origin(same) + assert same.headers["esb-oauth"] == "Bearer m" + + foreign = httpx.Request("POST", "https://attacker.example.com/x", headers={"esb-oauth": "Bearer m"}) + await _drop_credential_across_origin(foreign) + assert "esb-oauth" not in foreign.headers + finally: + _request_upstream_url.reset(url) + _request_resolved_auth_headers.reset(creds) + + +@pytest.mark.parametrize("resolved", [{"Authorization": "Bearer minted"}, {}, None]) +def test_the_openapi_arm_keeps_the_shared_client_when_no_guard_is_needed(resolved): + # Authorization is already stripped across origins by the HTTP client, so taking the guarded + # path for it would give up the shared connection pool for nothing. + from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import ( + _request_resolved_auth_headers, + _upstream_client, + ) + + token = _request_resolved_auth_headers.set(resolved) + try: + client = _upstream_client() + assert not client.client.event_hooks.get("request") + finally: + _request_resolved_auth_headers.reset(token) 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 0d639e1cb6a..ceb44de5576 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 @@ -1573,6 +1573,7 @@ class TestTemporaryMCPSessionEndpoints: existing_server.aws_region_name = None existing_server.aws_service_name = None existing_server.upstream_resource = None + existing_server.upstream_token_header = None mock_manager = MagicMock() mock_manager.get_mcp_server_by_id.return_value = existing_server @@ -1608,6 +1609,7 @@ class TestTemporaryMCPSessionEndpoints: existing_server.aws_region_name = None existing_server.aws_service_name = None existing_server.upstream_resource = None + existing_server.upstream_token_header = None for key, value in server_overrides.items(): setattr(existing_server, key, value) @@ -1639,6 +1641,23 @@ class TestTemporaryMCPSessionEndpoints: assert updated.credentials["client_id"] == "client-123" assert updated.credentials["client_secret"] == "secret-xyz" + def test_upstream_token_header_is_inherited_like_other_admin_config(self): + """It is admin config rather than a credential, so a session server derived from an existing + one must carry it. Miss it and the derived server silently sends its token to Authorization + while the original sends it to the gateway's header.""" + updated = self._inherit_with({}, upstream_token_header="esb-oauth") + + assert updated.credentials["upstream_token_header"] == "esb-oauth" + + def test_a_supplied_upstream_token_header_does_not_read_as_a_credential(self): + """It is in the admin-config key set, so submitting only it must still inherit the declared + app rather than reading as "the caller supplied real credentials".""" + updated = self._inherit_with({"upstream_token_header": "esb-oauth"}) + + assert updated.credentials["client_id"] == "client-123" + assert updated.credentials["client_secret"] == "secret-xyz" + assert updated.credentials["upstream_token_header"] == "esb-oauth" + def test_supplied_credential_still_wins_over_inheritance(self): """A caller that supplies a real credential keeps it; inheritance must not overwrite it.""" updated = self._inherit_with({"auth_value": "caller-token"}) @@ -2256,6 +2275,7 @@ class TestTemporaryMCPSessionEndpoints: aws_region_name=None, aws_service_name=None, upstream_resource=None, + upstream_token_header=None, ) built_server = generate_mock_mcp_server_config_record(server_id="temp-server") mock_manager = MagicMock() diff --git a/tests/test_litellm/types/test_mcp.py b/tests/test_litellm/types/test_mcp.py new file mode 100644 index 00000000000..5450ec4aa48 --- /dev/null +++ b/tests/test_litellm/types/test_mcp.py @@ -0,0 +1,87 @@ +"""Tests for the shared MCP header primitives. + +``same_header`` / ``has_header`` / ``without_header`` are the one owner of "is this the credential's +header", used by both MCP stacks and the upstream-credential resolver. They live here rather than in +either stack because a second implementation is exactly how an injected header came to shadow a +resolved credential on one path and not the other. +""" + +import pytest + +from litellm.types.mcp import ( + credential_redirect_hook, + crosses_origin, + has_header, + same_header, + without_header, +) + + +@pytest.mark.parametrize( + "a,b,expected", + [ + ("Authorization", "authorization", True), + ("ESB-OAuth", "esb-oauth", True), + ("esb-oauth", "esb-oauth", True), + ("esb-oauth", "esb_oauth", False), + ("esb-oauth", "Authorization", False), + ], +) +def test_header_names_compare_case_insensitively(a: str, b: str, expected: bool) -> None: + # RFC 7230 3.2. Every consumer of a credential slot routes through this, so a case-sensitive + # comparison anywhere would let an injected header shadow a resolved credential. + assert same_header(a, b) is expected + + +def test_without_header_drops_every_casing_and_keeps_the_rest() -> None: + headers = {"ESB-OAuth": "injected", "esb-oauth": "also injected", "X-Trace": "keep"} + assert without_header(headers, "esb-oauth") == {"X-Trace": "keep"} + + +def test_without_header_collapses_to_none_when_nothing_remains() -> None: + assert without_header({"Authorization": "Bearer x"}, "AUTHORIZATION") is None + assert without_header(None, "esb-oauth") is None + assert without_header({}, "esb-oauth") is None + + +def test_has_header_matches_any_casing() -> None: + assert has_header({"ESB-OAuth": "v"}, "esb-oauth") is True + assert has_header({"X-Other": "v"}, "esb-oauth") is False + assert has_header(None, "esb-oauth") is False + + +@pytest.mark.parametrize( + "target,expected", + [ + ("https://upstream.example.com/other", False), # same origin + ("https://upstream.example.com:443/other", False), # explicit default port + ("https://attacker.example.com/collect", True), # different host + ("http://upstream.example.com/collect", True), # scheme downgrade, same host + ("https://upstream.example.com:8443/other", True), # different port, same host + ("https://sub.upstream.example.com/x", True), # different host + ], +) +def test_origin_is_scheme_host_and_port_not_host_alone(target: str, expected: bool) -> None: + assert crosses_origin("https://upstream.example.com/mcp", target) is expected + + +def test_an_https_upgrade_of_the_same_host_is_not_crossing() -> None: + # HTTP clients exempt this when deciding to keep Authorization, so a credential slot that did + # not would lose the credential on every such redirect. + assert crosses_origin("http://upstream.example.com/mcp", "https://upstream.example.com/x") is False + assert crosses_origin("http://upstream.example.com/mcp", "http://upstream.example.com/x") is False + + +@pytest.mark.asyncio +async def test_the_hook_drops_the_slot_only_once_the_origin_changes() -> None: + import httpx + + hook = credential_redirect_hook("https://upstream.example.com/mcp", "esb-oauth") + + same = httpx.Request("GET", "https://upstream.example.com/other", headers={"esb-oauth": "Bearer x"}) + await hook(same) + assert same.headers["esb-oauth"] == "Bearer x" + + foreign = httpx.Request("GET", "https://attacker.example.com/x", headers={"esb-oauth": "Bearer x"}) + await hook(foreign) + assert "esb-oauth" not in foreign.headers diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/IdJagFormFields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/IdJagFormFields.tsx index df1d8d3436a..70d1bc40c18 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/IdJagFormFields.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/IdJagFormFields.tsx @@ -3,6 +3,7 @@ import React from "react"; import { SimpleTooltip } from "@/components/ui/tooltip"; import { MountedFormField } from "@/components/common_components/MountedFormField"; +import UpstreamTokenHeaderField from "./UpstreamTokenHeaderField"; import { requiredRule } from "@/components/common_components/formRules"; import { MultiSelect } from "@/components/shared/MultiSelect"; import { PasswordInput } from "@/components/shared/PasswordInput"; @@ -205,6 +206,7 @@ const IdJagFormFields: React.FC = ({ isEditing = false }) > {(control) => } + ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.test.tsx index 21bb2801e8c..9316cfa077c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.test.tsx @@ -286,4 +286,42 @@ describe("OAuthFormFields", () => { }); }); }); + + describe("token header field", () => { + it("renders on the M2M flow", () => { + render( + + + , + ); + expect(screen.getByPlaceholderText("Authorization")).toBeInTheDocument(); + }); + + it("renders on the interactive flow", () => { + render( + + + , + ); + expect(screen.getByPlaceholderText("Authorization")).toBeInTheDocument(); + }); + + it("submits its value under credentials.upstream_token_header", async () => { + const onFinish = vi.fn(); + render( + + + , + ); + fireEvent.change(screen.getByPlaceholderText("Authorization"), { target: { value: "esb-oauth" } }); + fireEvent.click(screen.getByText("Submit")); + await waitFor(() => { + expect(onFinish).toHaveBeenCalledWith( + expect.objectContaining({ + credentials: expect.objectContaining({ upstream_token_header: "esb-oauth" }), + }), + ); + }); + }); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx index 76d667039d4..41817f8c916 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/OAuthFormFields.tsx @@ -11,6 +11,7 @@ import { OAUTH_FLOW } from "@/components/mcp_tools/types"; import { MountedFormField } from "@/components/common_components/MountedFormField"; import { requiredRule } from "@/components/common_components/formRules"; import TokenEndpointAuthMethodField from "./TokenEndpointAuthMethodField"; +import UpstreamTokenHeaderField from "./UpstreamTokenHeaderField"; import { numberControl, parsesAsJson, @@ -175,6 +176,7 @@ const OAuthFormFields: React.FC = ({ {(control) => } + ) : ( <> @@ -237,6 +239,7 @@ const OAuthFormFields: React.FC = ({ {(control) => } + = ({ isEdi /> )} + ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/UpstreamTokenHeaderField.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/UpstreamTokenHeaderField.tsx new file mode 100644 index 00000000000..154d55c8cb4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/UpstreamTokenHeaderField.tsx @@ -0,0 +1,31 @@ +import { Info } from "lucide-react"; +import React from "react"; +import { SimpleTooltip } from "@/components/ui/tooltip"; +import { Input } from "@/components/ui/input"; + +import { MountedFormField } from "@/components/common_components/MountedFormField"; +import { textControl } from "./mcpFieldRules"; + +const UpstreamTokenHeaderField: React.FC = () => ( + + Token Header (optional) + + + + + } + name={["credentials", "upstream_token_header"]} + > + {(control) => ( + + )} + +); + +export default UpstreamTokenHeaderField; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.differential.cases.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.differential.cases.ts index c350ac085b6..ef8f728a609 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.differential.cases.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/editServerPayload.differential.cases.ts @@ -255,13 +255,28 @@ export const CASES: readonly DifferentialCase[] = [ }, // --- credentials filtering --- - // ADMIN_CONFIG_CREDENTIAL_KEYS is exactly ["upstream_resource"], so only that key - // takes the blank-to-explicit-null branch. A blank client_id is dropped instead. + // Only a key in ADMIN_CONFIG_CREDENTIAL_KEYS takes the blank-to-explicit-null branch, which is + // what makes it clearable: the backend merge preserves an omitted key forever. A blank client_id + // is dropped instead. { label: "blank upstream_resource becomes an explicit null", values: { ...ROOT, auth_type: "oauth2", credentials: { upstream_resource: "", client_secret: "keep" } }, ui: {}, }, + { + label: "blank upstream_token_header becomes an explicit null", + values: { ...ROOT, auth_type: "oauth2", credentials: { upstream_token_header: "", client_secret: "keep" } }, + ui: {}, + }, + { + label: "a set upstream_token_header rides the credentials blob", + values: { + ...ROOT, + auth_type: "oauth2", + credentials: { upstream_token_header: "esb-oauth", client_secret: "keep" }, + }, + ui: {}, + }, { label: "blank non-admin credential is dropped, not nulled", values: { ...ROOT, auth_type: "oauth2", credentials: { client_id: "", client_secret: "keep", scopes: [] } }, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.test.ts index dd9c8db6d30..13aec81d9e8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.test.ts @@ -262,7 +262,14 @@ describe("edit root: exact mounted set per auth configuration", () => { ...PERMS, "delegate_auth_to_upstream", ], - credentials: ["client_id", "client_secret", "token_endpoint_auth_method", "scopes", "upstream_resource"], + credentials: [ + "client_id", + "client_secret", + "token_endpoint_auth_method", + "scopes", + "upstream_resource", + "upstream_token_header", + ], }, ); }); @@ -286,7 +293,14 @@ describe("edit root: exact mounted set per auth configuration", () => { ...PERMS, "delegate_auth_to_upstream", ], - credentials: ["client_id", "client_secret", "scopes", "upstream_resource", "token_endpoint_auth_method"], + credentials: [ + "client_id", + "client_secret", + "scopes", + "upstream_resource", + "token_endpoint_auth_method", + "upstream_token_header", + ], }, ); }); @@ -306,7 +320,7 @@ describe("edit root: exact mounted set per auth configuration", () => { "env_vars", ...PERMS, ], - credentials: ["client_id", "client_secret", "scopes"], + credentials: ["client_id", "client_secret", "scopes", "upstream_token_header"], }, ); }); @@ -324,7 +338,7 @@ describe("edit root: exact mounted set per auth configuration", () => { "env_vars", ...PERMS, ], - credentials: ["client_id", "client_secret", "scopes"], + credentials: ["client_id", "client_secret", "scopes", "upstream_token_header"], }, ); }); @@ -344,6 +358,7 @@ describe("edit root: exact mounted set per auth configuration", () => { ...PERMS, ], credentials: [ + "upstream_token_header", "id_jag_resource_token_endpoint", "client_id", "client_secret", @@ -434,7 +449,14 @@ describe("create root: exact mounted set per configuration", () => { ...PERMS, "delegate_auth_to_upstream", ], - credentials: ["client_id", "client_secret", "scopes", "upstream_resource", "token_endpoint_auth_method"], + credentials: [ + "client_id", + "client_secret", + "scopes", + "upstream_resource", + "token_endpoint_auth_method", + "upstream_token_header", + ], }, ); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.ts b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.ts index 22f0146afc9..af9cbb58b2b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/mountedServerFields.ts @@ -24,6 +24,7 @@ const OAUTH_M2M_CREDENTIALS = [ "token_endpoint_auth_method", "scopes", "upstream_resource", + "upstream_token_header", ] as const; const OAUTH_INTERACTIVE_CREDENTIALS = [ @@ -32,6 +33,7 @@ const OAUTH_INTERACTIVE_CREDENTIALS = [ "scopes", "upstream_resource", "token_endpoint_auth_method", + "upstream_token_header", ] as const; const OAUTH_INTERACTIVE_ROOT = [ @@ -44,6 +46,7 @@ const OAUTH_INTERACTIVE_ROOT = [ ] as const; const ID_JAG_CREDENTIALS = [ + "upstream_token_header", "id_jag_resource_token_endpoint", "client_id", "client_secret", @@ -100,7 +103,7 @@ const authSubtreeCredentials = ({ authType, oauthFlowType }: AuthSubtreeGates): ]; } if (authType === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE) { - return [...authValue, "client_id", "client_secret", "scopes"]; + return [...authValue, "client_id", "client_secret", "scopes", "upstream_token_header"]; } if (authType === AUTH_TYPE.OAUTH2_ID_JAG) { return [...authValue, ...ID_JAG_CREDENTIALS]; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index 67e51ab23aa..bc14e7a87ec 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -151,7 +151,7 @@ const DECLARED_APP_CREDENTIAL_KEYS = ["client_id", "client_secret"] as const; // would destroy admin input), but it must stay OUT of the declared-app set: whether an app exists is // a distinct question that gates the "app may not match upstream" warning, and a server using dynamic // client registration can set a resource indicator while having no app at all. -export const ADMIN_CONFIG_CREDENTIAL_KEYS = ["upstream_resource"] as const; +export const ADMIN_CONFIG_CREDENTIAL_KEYS = ["upstream_resource", "upstream_token_header"] as const; // Minted token material the oauth2 authorize path writes beside the app keys; stripped from restored // snapshots and from any credentials that transit to the temp-session preview so a stale token never