diff --git a/litellm/proxy/_experimental/mcp_server/v2_port_bodies.py b/litellm/proxy/_experimental/mcp_server/v2_port_bodies.py index c00051e7cc8..fd046e7c7ab 100644 --- a/litellm/proxy/_experimental/mcp_server/v2_port_bodies.py +++ b/litellm/proxy/_experimental/mcp_server/v2_port_bodies.py @@ -113,6 +113,16 @@ class HttpxClientCredentialsFetcher: async def fetch( self, config: ClientCredentialsConfig ) -> Result[StoredToken, CredError]: + if ( + config.client_id is None + or config.client_secret is None + or config.token_url is None + ): + return Error( + CredError.of_misconfigured( + "client_credentials requires client_id, client_secret, and token_url" + ) + ) data = { "grant_type": "client_credentials", "client_id": config.client_id, diff --git a/litellm/proxy/_experimental/mcp_server/v2_resolver_bridge.py b/litellm/proxy/_experimental/mcp_server/v2_resolver_bridge.py index 42d464eaea4..b72fd390f1f 100644 --- a/litellm/proxy/_experimental/mcp_server/v2_resolver_bridge.py +++ b/litellm/proxy/_experimental/mcp_server/v2_resolver_bridge.py @@ -200,18 +200,18 @@ def to_server_spec(server: MCPServer) -> Optional[ServerSpec]: scopes=tuple(server.scopes or ()), ), ) - if ( - server.has_client_credentials - and server.client_id - and server.client_secret - and server.token_url - ): + if server.has_client_credentials: + # Mode selector only (oauth2_flow == client_credentials); completeness is no longer a guard. + # An incomplete M2M config still builds, and the _client_credentials arm raises misconfigured + # at resolve time instead of returning None and deferring to v1. return ServerSpec( server_id=server.server_id, resource=resource, config=ClientCredentialsConfig( client_id=server.client_id, - client_secret=SecretStr(server.client_secret), + client_secret=( + SecretStr(server.client_secret) if server.client_secret else None + ), token_url=server.token_url, scopes=tuple(server.scopes or ()), ), diff --git a/litellm/proxy/gateway/mcp/outbound_credentials/types.py b/litellm/proxy/gateway/mcp/outbound_credentials/types.py index 2ee085e9f8d..526848f227a 100644 --- a/litellm/proxy/gateway/mcp/outbound_credentials/types.py +++ b/litellm/proxy/gateway/mcp/outbound_credentials/types.py @@ -158,9 +158,12 @@ class ClientCredentialsConfig(BaseModel): model_config = ConfigDict(frozen=True) kind: Literal[AuthSpecKind.client_credentials] = AuthSpecKind.client_credentials - client_id: str - client_secret: SecretStr - token_url: str + # Optional so the config can be built incomplete: the values may be supplied at runtime (token_url + # via RFC 8414 discovery, client_id/secret via DCR) and the _client_credentials arm raises + # CredError.misconfigured when a needed field is still absent at resolve time. + client_id: str | None = None + client_secret: SecretStr | None = None + token_url: str | None = None scopes: tuple[str, ...] = () diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_v2_resolver_bridge.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_v2_resolver_bridge.py index f53a177d5a3..96bca925a78 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_v2_resolver_bridge.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_v2_resolver_bridge.py @@ -150,6 +150,33 @@ async def test_client_credentials_maps_to_config(): assert spec.config.scopes == ("a", "b") +async def test_m2m_incomplete_creds_resolves_misconfigured(): + # A client_credentials server missing client_id/secret now BUILDS a spec (the completeness + # pre-check was dropped) and resolve() raises misconfigured at the arm, instead of to_server_spec + # returning None and deferring to v1. + from litellm.proxy._experimental.mcp_server.v2_resolver_bridge import ( + provider, + to_server_spec, + to_subject, + ) + from litellm.proxy.gateway.mcp.result import Error + + server = MCPServer( + server_id="m2m-incomplete", + name="m2m-incomplete", + transport=MCPTransport.http, + url="https://up.example/mcp", + auth_type=MCPAuth.oauth2, + oauth2_flow="client_credentials", + token_url="https://idp/token", + ) # has_client_credentials, but no client_id / client_secret + spec = to_server_spec(server) + assert spec is not None # builds incomplete now (was None -> defer to v1 before) + result = await provider().resolve(to_subject(None, None), spec) + assert isinstance(result, Error) + assert result.error.tag == "misconfigured" + + async def test_client_credentials_graft_end_to_end(v2_on, monkeypatch): # M2M flows through the real fetcher; mock the IdP token endpoint and assert the Bearer. from litellm.proxy._experimental.mcp_server import v2_port_bodies