refactor(mcp): make AuthorizationCodeConfig fields optional (discovery + DCR)

The four fields (client_id, client_secret, authorization_url, token_url) were required, which
only models the legacy manual-registration case. Real MCP OAuth (Slack, Notion, Atlassian)
discovers endpoints (RFC 9728 -> RFC 8414) and registers the client via DCR (RFC 7591), so an
admin provides none of them - the discovered endpoints and DCR-registered client are obtained
at runtime and persisted by the AS surface, and resolve() reads only the per-user token from
the TokenStore. Make the four optional manual overrides; scopes is the one genuine config
field. The rejects-missing-required test moves to client_credentials (whose creds are
provisioned, not DCR'd), and a new test pins that an empty authorization_code config is valid.
This commit is contained in:
Tin Chi Lo 2026-06-17 19:05:28 -07:00
parent 108e75c3cd
commit 02e752886d
2 changed files with 21 additions and 7 deletions

View file

@ -138,15 +138,22 @@ ApiKeyScheme = Literal["bearer", "apikey", "basic", "token", "raw"]
class AuthorizationCodeConfig(BaseModel):
"""Per-user 3LO; the gateway is the OAuth client and stores the user's token."""
"""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
(RFC 7591), so the common case carries none of the fields below; they are optional manual
overrides for IdPs without discovery / DCR. The discovered endpoints and the DCR-registered
client are persisted by the AS surface, not here; `resolve()` reads the per-user token from
the `TokenStore`.
"""
model_config = ConfigDict(frozen=True)
kind: Literal[AuthSpecKind.authorization_code] = AuthSpecKind.authorization_code
client_id: str
client_secret: SecretStr
authorization_url: str
token_url: str
scopes: tuple[str, ...] = ()
client_id: str | None = None
client_secret: SecretStr | None = None
authorization_url: str | None = None
token_url: str | None = None
class ClientCredentialsConfig(BaseModel):

View file

@ -186,9 +186,16 @@ def test_auth_spec_kind_is_derived_from_config():
def test_discriminated_union_rejects_config_missing_required_fields():
# authorization_code requires client_id/secret/urls; an empty body must fail at construction.
# client_credentials requires client_id/secret/token_url; an empty body must fail.
with pytest.raises(ValidationError):
_spec({"kind": "authorization_code"})
_spec({"kind": "client_credentials"})
def test_authorization_code_config_allows_discovery_defaults():
# Discovery + DCR: endpoints and client creds are obtained at runtime, so an empty
# authorization_code config is valid - the fields are optional manual overrides.
spec = _spec({"kind": "authorization_code"})
assert isinstance(spec.config, AuthorizationCodeConfig)
def test_discriminated_union_picks_the_variant_by_kind():