If you operate the MCP server, you pre-register the clients. A client_id, a secret, redirect URIs in the YAML. That is still first in the 2026-07-28 spec, and it is the default in mcp-data-platform, also hosted as Plexara: oauth.clients, DCR off. An MCP client that has those values presents them at /authorize and /token. Nothing about that went away.
The spec also has answers for a client that has never met this authorization server: Dynamic Client Registration, and Client ID Metadata Documents. Those are the open-world case. They are not a replacement for a list of known clients. A separate job is who authenticates the human. Enterprises already run Keycloak, Auth0, Okta, or Azure AD. Those directories have the people. Making the MCP server an identity provider copies that directory. Pointing Claude at Keycloak directly means registering Claude there and teaching Keycloak the RFC 9728 document the resource server still has to publish. The third shape is a broker. The MCP process is the authorization server. Keycloak is still the IdP. The known clients still go through it.
I built that path in mcp-data-platform, also hosted as Plexara. This post is the broker, as it sits in pkg/oauth, tokens signed with jwt/v5 v5.3.1.
This is the tenth post in MCP by Design. The earlier notes covered Go, composition, steering, knowledge, testing, Starlark, Hive, session handles, and four tools. This one is the broker. It is grounded in the open-source
txn2/mcp-data-platform, also available hosted as Plexara.
§Two Jobs
The Wire post is the resource-server half: a 401, WWW-Authenticate, protected-resource metadata, a token bound to this server, and the passthrough ban. This post is the other half. Something has to issue that token after a human authenticates. That something does not have to own the passwords.
The alternatives are real. Keycloak as the only authorization server is the shape in wadahiro’s Go example and the mcp-use Keycloak template: Claude registers at the realm, Keycloak mints the JWT, the MCP process verifies JWKS and never proxies the login. Auth0 and Okta are the same job with a different bill. Envoy, Kong, and Red Hat Connectivity Link will terminate OAuth as infrastructure at the cluster edge. giantswarm/mcp-oauth and haakco/mcp-kit are Go libraries you vendor; the kit even ships a consent handler.
Those win when the IdP already speaks DCR or Client ID Metadata Documents, or when you are willing to make Keycloak the AS Claude talks to. The remaining case is an existing Keycloak realm that has the users and an MCP process that has to satisfy Claude. I do not want a second user directory. The broker is that case. Login stays in Keycloak. The MCP process mints MCP tokens. Pre-registered clients in oauth.clients are how this deployment starts. DCR is an extra, off by default.
This piece does not install Keycloak. It does not cover the portal cookie in pkg/browsersession. It does not implement RFC 7591 from scratch on the page. Outbound OAuth to a vendor API is a different flow and a different table; that is a later note. It covers a working slice: known clients, PKCE S256, Claude talks to this process, this process talks to Keycloak, the JWT that comes back is one the resource-server half already knows how to check.
§Three Roles
Claude is the OAuth client. This process is the authorization server. Keycloak is the identity provider. Two hops, two codes, two tokens.
A pre-registered client, claude-desktop in the YAML, hits GET /authorize with response_type=code, a redirect_uri that already matches, and a PKCE S256 challenge. The handler does not authenticate the human. It writes an authorization state, keyed by a 16-byte upstream state value, and returns 302 to Keycloak. The Keycloak URL carries this process’s static client_id, this process’s /oauth/callback, and scope=openid email profile. The first attempt also sends prompt=none so a browser that already has a Keycloak session never sees a form.
The browser returns to /oauth/callback with Keycloak’s code. The state is consumed so it cannot be replayed. The upstream code is exchanged with the static client secret. Claims are read from the ID token, then filled from the access token, sub becoming the MCP user id. This process then mints its own authorization code, ten-minute TTL, bound to Claude’s client_id, redirect, and challenge, and 302s Claude to the registered redirect with that code and Claude’s original state.
Claude POSTs /token with the code, the verifier, and its client secret, basic auth or form. The verifier is checked against the stored S256 challenge. The response is a JWT access token, one hour, and a refresh token, thirty days, rotated on use.
The well-known document that starts the client is this process, not Keycloak:
curl https://mcp.example.com/.well-known/oauth-authorization-server
That JSON names authorization_endpoint, token_endpoint, registration_endpoint, code_challenge_methods_supported: ["S256"], and grant_types_supported: ["authorization_code", "refresh_token"]. Paths are advertised without the /oauth prefix because Claude Desktop looks there; ServeHTTP accepts both /authorize and /oauth/authorize. Upstream endpoints are not hardcoded. They come from Keycloak’s /.well-known/openid-configuration on first use, cached for the process lifetime, with an explicit override only when that document is broken.
A database DSN puts in-flight state, clients, codes, and refresh tokens in Postgres so the callback can land on a different replica. Without it, state is memory, which is a single replica. Refresh tokens and authorization codes are stored as hex SHA-256 digests; a backup does not yield a bearer. Client secrets are bcrypt. Access tokens are HS256 JWTs with a kid derived from the signing key, so a rotation can keep the previous key for verification.
§Register, Then Prove Possession
DCR is RFC 7591. PKCE is RFC 7636, S256 only. OAuth 2.1 dropped plain. The 2026-07-28 spec prefers Client ID Metadata Documents and marks DCR as the fallback. This server does not fetch a URL-shaped client_id. It still does DCR, and it is off by default.
When it is on, POST /register is unauthenticated, rate-limited (10 requests per minute per IP, burst 3), and denied unless allowed_redirect_patterns is set or allow_all_redirect_uris is an explicit escape hatch. Loopback HTTP is accepted, RFC 8252 native-app. Plain HTTP to a non-loopback host is not. Private-use schemes need a pattern; allow_all will not take them. The response is a generated client_id and client_secret. Dynamically registered clients that never exchange a token are reaped after 24 hours.
PKCE is required for every client this platform wires, pre-registered and DCR. A missing code_challenge fails /authorize. A method other than S256 fails /authorize, not later at /token. The verifier is 43 to 128 characters of unreserved URI characters. The token endpoint hashes it and compares.
A signing key is required on HTTP. openssl rand -base64 32. Omit it and the process refuses to start, because an ephemeral per-process key means replica A mints tokens replica B rejects. allow_ephemeral_signing_key is the single-replica escape hatch.
What now exists, on a configured host: /.well-known/oauth-authorization-server, /register, /authorize, /callback, /token, and a JWT the resource-server half in pkg/auth already verifies. aud is the issuer URL. The requesting client is a client_id claim, not the audience. Upstream roles and email sit under a nested claims object, which is how Keycloak’s realm_access.roles reach a persona without this process becoming an IdP.
claims := jwt.MapClaims{
"iss": s.config.Issuer,
"sub": userID,
"aud": s.config.Issuer,
"client_id": clientID,
"exp": exp.Unix(),
"scope": scope,
}
if len(userClaims) > 0 {
claims["claims"] = userClaims
}
That JWT is not a session handle. The token names who authenticated. Gated tools still want dps_… in the argument.
§The Static Client ID
The spec’s confused deputy write-up is about this exact shape. Quote:
MCP proxy servers MUST implement per-client consent and proper security controls
The vulnerable conditions are a static client_id against the upstream IdP, DCR so each MCP client gets its own id, a consent cookie at the IdP after the first approval, and no MCP-owned consent step before the 302. An attacker registers a client whose redirect_uri they control, sends the user a link, and rides the Keycloak cookie that already trusts mcp-data-platform. Keycloak skips consent. This process mints an MCP code and redirects it to the attacker.
This process uses a static client_id against Keycloak. That is the upstream YAML: one mcp-data-platform client, one secret, one callback. DCR is off unless you turn it on. When it is on, the bound is redirect-URI patterns, exact match against the registered URI at /authorize, single-use state, a one-hour state TTL so the login form is not racing a ten-minute code. There is no per-client consent page before the 302, and the first hop sends prompt=none, which is silent SSO when the browser already has a Keycloak session.
Caution: turning DCR on without a consent registry leaves the spec hole open. Redirect patterns close the easy version (an https://attacker.example/callback that matches no pattern never registers). They are not the MUST. haakco/mcp-kit’s consent package is the other library’s answer. This broker does not ship one.
The trust-boundary post is a different deputy: a server speaking into the model. The Wire post’s passthrough ban is a different one still: do not accept a token minted for someone else. This one is the login hop.
Streamable HTTP is how Claude presents the bearer after this flow. stdio never sees it; a local subprocess takes credentials from the environment.
§Summary
Claude needs an authorization server that will register a client it has never seen, or accept one you pre-registered. Keycloak already has the people. Those are two jobs. Copying the directory into the MCP process makes a second IdP. Pointing Claude at Keycloak copies MCP’s registration and resource-metadata requirements onto the realm. The broker sits in between: DCR and PKCE S256 on this process, login on Keycloak, a JWT whose aud is this issuer.
In mcp-data-platform that is pkg/oauth and the resource-server check in pkg/auth. Keycloak still owns the passwords. The token names a user. It does not pick a warehouse credential.