5.3 KiB
ADR-019: MCP Client identity -- production extension point (mTLS / workload identity)
Status: Documented (spike), not implemented in the RI (EP-01-T04, could).
Related: this ADR is the canonical record of this decision. See RI/docs/AS-BUILT.md and
RI/docs/interfaces/identity-oidc.md for implementation and validation context. Also relevant:
README.md §19, RF-02, Axiom 2.
Context
The RI's identity boundary (src/emcp_bus/identity/authn.py, EP-01-T03) is
OAuth 2.1 client_credentials against Keycloak (EP-01-T01): every MCP Client
authenticates with a client_id + secret, gets a short-lived JWT, and the
Gateway validates that JWT's signature/issuer/audience/expiry against the
IdP's JWKS before trusting the azp claim as the caller's identity.
This is a deliberate, confirmed decision for the RI (not a placeholder): OAuth 2.1 satisfies every property README.md §19 actually requires of an identity mechanism -- short-lived, revocable, audience-bound, verifiable without a shared long-term secret in the request path. Real enterprise deployments commonly go further, using mTLS (mutual TLS client certificates) or a cloud provider's workload identity federation (e.g. SPIFFE/SPIRE, AWS IAM roles for service accounts, GCP Workload Identity, Azure Managed Identity) instead of -- or in addition to -- a bearer JWT. This ADR documents how that extension plugs into the RI's existing seam, without requiring one, so a production fork isn't blocked on a redesign.
Decision
Do not implement mTLS/workload identity in the RI. Document the
extension point instead, so EP-01-T03's contract is provably stable under
it.
The extension point
TokenValidator.validate(token: str) -> AuthenticatedClient is the only
function in the RI that produces client identity for authorization purposes
(emcp_bus.gateway.server.on_list_tools/on_call_tool both call
get_access_token(), which is populated by the SDK's TokenVerifier
protocol wrapping this same validator -- see
emcp_bus.identity.token_verifier.MCPTokenVerifier). Everything downstream
of it -- EntitlementManager.resolve, the PDP, the audit/correlation layer
(EP-08) -- consumes only the resulting AuthenticatedClient.client_id. None
of that code cares how the identity was established.
A production deployment adding mTLS or workload identity would:
-
Implement a second identity source honoring the same output contract:
class MTLSClientValidator: def validate(self, request: ...) -> AuthenticatedClient: ...fed from the terminating proxy/load balancer's verified client certificate (a
X-Client-Cert-CNstyle header from a trusted mTLS terminator, or the ASGIscope["extensions"]["tls"]a compliant server exposes) instead of a JWT.AuthenticatedClient.client_idwould be the certificate's Subject/SAN, mapped 1:1 to aClientRegistration.client_idexactly the way a JWT'sazpis today -- no other model in the RI changes:ClientRegistration,ClientProfile,EntitlementManager, the PDP contract, and the audit schema are all already identity-mechanism agnostic. -
Provide an alternate
build_auth()-equivalent inemcp_bus/gateway/server.pythat wires the new validator in instead ofMCPTokenVerifier, gated by deployment config -- the same shape as today'sbuild_auth()returning(None, None)for the "no OIDC configured" mode. A hybrid deployment (mTLS for one class of backend service, OAuth for interactive agents) is possible by making theTokenVerifier/equivalent selection per-listener rather than per-process, which the SDK'sstreamable_http_app(auth=..., token_verifier=...)parameters already support per app instance. -
Cloud workload identity (SPIFFE/SPIRE, IRSA, Workload Identity, Managed Identity) is a variant of the same shape: the "verify a credential the caller cannot forge, and extract a stable identifier from it" contract is identical, only the credential format and verification library differ. None of it requires touching
EntitlementManager, the PDP, the Fabric router (EP-06-T01), or the audit/correlation layer (EP-08-T02/T03).
What must not change
AuthenticatedClientshape (client_id,issuer,expires_at): every downstream consumer types against this, not against a JWT-specific claim set.- Fail-closed contract: any identity mechanism's failure path must raise
(an
AuthenticationError-shaped exception) rather than fall back to an unauthenticated or agent-declared identity (README.md §5, Axiom 2) -- this is the actual security property EP-01-T03's acceptance criteria test, and it is mechanism-independent. - No new privileged path for
clientInfo: whatever the identity mechanism, only a value verified independently of the MCP request body may ever populateclient_id.
Consequences
- Confirmed for the RI: reduces EP-01's implementation effort without
weakening any of README.md §19's desirable identity properties (all are
satisfied by OAuth 2.1
client_credentialsagainst Keycloak). - A production fork adding mTLS/workload identity changes exactly one
module (
identity/) and one wiring function (build_auth-equivalent); every other component in the RI (Entitlement Manager, PDP, Fabric, Registry, audit/correlation, approval) requires zero changes.