Skip to content
DAkeycloakcimdoauthmcpopen-source

I had to patch Keycloak to make CIMD work

DCR worked, but I wanted the proper CIMD path. It took two surgical Keycloak patches and real Claude and ChatGPT tests.

DCR worked. I could have stopped there.

Claude and ChatGPT could automatically register OAuth clients in Keycloak and complete sign-in to my remote MCP server. But every test left another client record behind, and the current MCP specification describes CIMD as the preferred approach when the client and authorization server have no prior relationship. DCR is the fallback for backwards compatibility and specific requirements.

I did not want to build the permanent solution on the fallback.

That decision led to a custom Keycloak image, two surgical Java patches, several upstream issues, and real authorize requests from both ChatGPT and Claude. It was more work than saying “just use DCR.” It was also how we learned what actually needed fixing.

This is the technical continuation of When my MCP server met Claude and ChatGPT. The first post covers the platform experience and the three Agentics MCP servers. This one goes all the way down into Keycloak.

Why I did not stop at DCR

Dynamic Client Registration does exactly what its name suggests. An unknown client sends its metadata to the authorization server's registration_endpoint; the server creates a client record and returns a client_id.

It is an important fallback, and it made our first Claude flow work. But in a public remote MCP product, Claude, ChatGPT, and other major clients already have stable public identities. With CIMD, they can use an HTTPS URL as the client_id, and the authorization server can fetch and validate metadata from that URL.

The current MCP authorization specification makes the priority fairly clear:

  • authorization servers and MCP clients SHOULD support CIMD;
  • they MAY support DCR;
  • DCR is described as a fallback for backwards compatibility or specific requirements.

My objection to DCR was not that the protocol is invalid. It gave me the wrong permanent operating model: more client records, more registration state, and an open registration surface even when the client could already identify itself with a public document.

I wanted URL-based client identity as the default and DCR retained as fallback.

The grey text that started it

I did not know CIMD until ChatGPT showed the option as unavailable in its connector setup.

human prompt
3 lines2026-07-17 14:50
ChatGPT will dynamically register an OAuth client using the Registration URL in the OAuth endpoints section. CIMD is unavailable because the server did not advertise CIMD support. is this something we can do? how is that advertised?

Keycloak had just gained experimental support for OAuth Client ID Metadata Documents. We upgraded to 26.7, enabled cimd at both image build and runtime, and installed a client policy with:

  • condition: client-id-uri;
  • executor: client-id-metadata-document;
  • HTTPS as the only URI scheme;
  • explicit trusted domains.

This follows the model Keycloak documents in Integrating with Model Context Protocol: the feature flag advertises the capability, while the policy and executor actually process a URL-formatted client_id.

The build-time and runtime feature lists had to match. Our AppHost therefore set:

KC_FEATURES=opentelemetry,cimd

And the Docker image was built with:

RUN /opt/keycloak/bin/kc.sh build \
  --db=${KC_DB} \
  --health-enabled=true \
  --features=opentelemetry,cimd

Once discovery returned client_id_metadata_document_supported: true, ChatGPT immediately switched from DCR to CIMD.

That was where the real test began.

First failure: ChatGPT had a field Keycloak did not know

ChatGPT sent a URL-based client_id. Keycloak fetched the document, but the authorization flow died with Client metadata fetch failed.

The logs showed a Jackson UnrecognizedPropertyException. ChatGPT's document contained token_endpoint_auth_methods_supported, which Keycloak's OIDCClientRepresentation did not know. Instead of ignoring the field, Keycloak rejected the whole document.

That field is also an important difference between the clients. ChatGPT did not set the unambiguous singular token_endpoint_auth_method. Instead, its document advertised both none and private_key_jwt in the plural field and published a jwks_uri. It is therefore misleading to classify the ChatGPT client as unambiguously public in the same way as Claude.

Comparison of the actual CIMD metadata published by ChatGPT and Claude.

“Connector-specific” and “shared” only describe the shape of their client_id. ChatGPT generates a metadata URL for the particular connector, such as https://chatgpt.com/oauth/{connector-id}/client.json. Claude uses one shared URL for its MCP client: https://claude.ai/oauth/mcp-oauth-client-metadata. Both are stable HTTPS identities Keycloak can fetch; the difference is whether the URL belongs to one connector or is shared by Claude.

Token authentication is a separate distinction:

  • none means the client does not authenticate with a client secret or cryptographic signature at the token endpoint. It is a public client, and PKCE protects the authorization-code flow.
  • private_key_jwt means the client can sign a JWT using its private key. Keycloak verifies that signature through the client's jwks_uri.
  • ChatGPT's document advertises both as supported options. That does not mean ChatGPT uses both in one token exchange, and our logs did not conclusively prove which method the successful flow selected.
  • Claude explicitly selects none, making it unambiguously public.

This was not only a CIMD problem. The older Keycloak issue #14946 describes the same underlying failure for DCR: an unknown JSON field breaks registration even though RFC 7591 requires authorization servers to ignore client metadata they do not understand.

I did not want to rewrite ChatGPT's document in a proxy. That would hide the interoperability failure and bind the workaround to one client. We needed to prove the smallest correct change in Keycloak.

The patch was one annotation:

@JsonIgnoreProperties(ignoreUnknown = true)
public class OIDCClientRepresentation {
    // upstream class
}

But I did not want to fork the entire Keycloak distribution for one annotation either.

A Docker image with surgical class overlays

Our keycloak/Dockerfile uses a multi-stage build to patch exactly the class we need:

  1. Start from the exact Keycloak version.
  2. Fetch the corresponding .java file from the Keycloak tag.
  3. Add the tiny source patch with sed.
  4. Use grep to verify that the patch matched.
  5. Compile only that class with javac against Keycloak's own runtime JARs.
  6. Put the compiled class back into the relevant JAR using jar uf.
  7. Overlay the patched JAR before the normal optimised kc.sh build.

We still build on the official image. The patch is visible, small, and tied to a specific upstream version. If upstream changes the source, the grep guards fail and stop the build instead of silently producing something else.

The path from a real reproduction through a minimal patch and proof to upstream.

The first patch moved the ChatGPT test from HTTP 400 to a real sign-in page. The CIMD executor then validated every URL field in the document, not only client_id and the redirect URI. ChatGPT referenced its logo on persistent.oaistatic.com, so our allowlist had to include OpenAI's legitimate asset domains.

That is not an annoying formality. CIMD causes the authorization server to fetch a foreign document. Trusted-domain rules, size limits, HTTPS, and SSRF protections are central to the security model.

Once those were correct, the Keycloak events showed the full ChatGPT flow:

CLIENT_REGISTER  client=https://chatgpt.com/oauth/…/client.json  err=-
LOGIN            client=https://chatgpt.com/oauth/…/client.json  err=-
CODE_TO_TOKEN    client=https://chatgpt.com/oauth/…/client.json  err=-

No DCR. The URL was the client's identity.

Second failure: Claude's document was a public client with jwt-bearer

The next morning I tested the personal MCP server from Claude Desktop. The Keycloak log showed:

client_id=https://claude.ai/oauth/mcp-oauth-client-metadata

Claude supported CIMD too. But its authorization request ended in invalid_request.

Claude's document described a public client with token_endpoint_auth_method: none while also listing urn:ietf:params:oauth:grant-type:jwt-bearer in grant_types. Keycloak's DescriptionConverter enabled the grant and then threw because Keycloak would not permit it for a public client:

JWT authorization grant cannot be enabled in a public client

The quick workaround was obvious: pre-register a dedicated claude-desktop client and paste its ID into the connector settings. That would skip CIMD and unblock my own test.

It was not the solution I wanted to build.

human prompt
1 lines2026-07-18 08:08
i dont care about shortcome fixes, do the proper thing. We should do the CIMD support.

We paused to establish whether Claude's document was actually wrong. jwt-bearer could look like confidential-client authentication, but the document described the authorization grant from RFC 7523. Public clients can advertise it. If Keycloak does not want to offer that grant, it can keep the grant disabled and accept the rest of the valid metadata.

This turned out to be the known Keycloak issue #50362, “Claude Desktop authentication rejected by Keycloak,” with draft fix PR #50411.

Our second Dockerfile stage patched DescriptionConverter. It replaced the fatal exception with an explicit disable:

setOidcGrantEnabled(
    client,
    OIDCConfigAttributes.JWT_AUTHORIZATION_GRANT_ENABLED,
    Boolean.FALSE
);

The patch did not give Claude access to jwt-bearer. It merely allowed Keycloak to disable the unsupported grant and continue with the rest of the valid registration.

Prove the same request, not a pretty fixture

A patch is not proven because the Docker image builds.

We reused Claude's exact authorization URL from the failed flow. Before the patch it returned 400 invalid_request. After building, retagging, and restarting only the Keycloak resource, it returned 200 and the sign-in page.

We then completed the connector flow all the way to the personal MCP server. OAuth can be correct while the subsequent MCP route still fails for a different reason, so we followed the chain from metadata fetch to authorization, login, code exchange, and the first authenticated MCP call.

That is a discipline I want to keep:

  • reproduce with a real client;
  • isolate the failing layer;
  • make the smallest patch;
  • replay the same request;
  • verify the whole flow afterwards.

Synthetic metadata fixtures are useful tests, but they do not necessarily contain the fields and combinations real clients publish.

A local patch needs an exit

My goal is not to maintain a private Keycloak fork forever.

Both Dockerfile stages therefore carry an explicit upstream reference and a TODO to remove them when an equivalent fix ships. The patches are tied to reproducible problems and existing issues, not a vague “Keycloak does not work.”

The local overlay serves two purposes:

  1. It proves that the proposed change fixes the real ChatGPT and Claude flows.
  2. It provides concrete evidence for the upstream conversation: client document, stack trace, minimal code change, and before/after request.

That is how I prefer to push back on open source. Not by building a permanent workaround around the project, and not by only filing an issue. First make the failure reproducible. Then demonstrate a small fix that resolves it without granting the client additional permissions. Finally move the patch upstream and delete the Docker overlay.

I do not need upstream to adopt my architecture. I need our findings to be precise enough for maintainers to evaluate.

Where it landed

The Agentics Keycloak image can now accept the real CIMD documents published by ChatGPT and Claude:

  • CIMD is enabled as an experimental Keycloak feature;
  • a client policy restricts URL-based IDs to HTTPS and trusted domains;
  • ChatGPT's unknown metadata no longer crashes the parser;
  • legitimate asset domains are included in the allowlist;
  • Claude's unsupported public-client grant is disabled without rejecting the client;
  • DCR remains available as fallback, but it is not the preferred path.

The first post ended with the observation that a remote MCP server is more than some tools over HTTP. After this work I would add one more thing:

It is also an integration test for the internet's OAuth implementations.

Specifications only become real when ChatGPT's document, Claude's document, and Keycloak's parser meet in the same request. That is where the two important failures appeared. And that is where the proper fixes could be proven.