refactor(mcp/v2): float misconfigured M2M up as a typed error instead of deferring to v1

Make ClientCredentialsConfig's client_id/client_secret/token_url optional (matching the other OAuth
configs), and move the completeness check out of to_server_spec's m2m guard into the
_client_credentials fetcher: an incomplete M2M server now builds a spec and resolve() raises
CredError.misconfigured at the arm, rather than to_server_spec returning None and silently deferring
the misconfig to v1. The fetcher guard also narrows the now-optional fields (no new type errors).
Optional makes the fields discovery/DCR-ready (token_url via RFC 8414, client_id/secret via DCR) later.

Validated: 32 bridge tests, including a new test that an incomplete client_credentials server resolves
to misconfigured.
This commit is contained in:
Tin Chi Lo 2026-06-22 14:12:14 -07:00
parent d291593f1f
commit 74e516e2b9
4 changed files with 50 additions and 10 deletions

View file

@ -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,

View file

@ -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 ()),
),

View file

@ -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, ...] = ()

View file

@ -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