Skip to main content
Glama
atulram

Keycloak Realm Inspector

by atulram

Keycloak Realm Inspector

An MCP server that is purely an OAuth 2.0 protected resource, for a KeyConf 2026 talk on Client ID Metadata Documents (draft-ietf-oauth-client-id-metadata-document-02).

It issues nothing — no /authorize, no /token, no client secret. Keycloak issues tokens; this server validates them against Keycloak's JWKS, serves RFC 9728 discovery, and exposes three tools that make CIMD visible from inside the resource server.

Three parties, deliberately kept apart

Keycloak

the authorization server. --features=cimd, KC_HTTP_RELATIVE_PATH=/auth, port 8080

The MCP client

publishes its metadata document at a URL it controls. That URL is its client_id. Served on port 9000

This server

the protected resource. Port 9001

This server does not host, read or fetch the client's metadata document. There is no /client-metadata.json route and no local copy. Keycloak fetches that URL over the network during /authorize. In this codebase the URL only ever appears as an opaque string arriving in the azp claim — which is the entire point of the demo, and why the document is served by a separate process on a separate port.

Run

Keycloak is assumed to be up, with the realm created and the CIMD client policy already applied. The client, its metadata document and the token helper live alongside this repo in ../cimd-demo/auth-server/.

# terminal 1 — the client's document. Its own party, its own port.
cd ../cimd-demo/auth-server && python -m http.server 9000

# terminal 2 — the resource server
pip install -r requirements.txt
python server.py                     # http://localhost:9001

# terminal 3 — get a token, then point an MCP client at :9001/mcp
cd ../cimd-demo/auth-server && python get-token.py

The flow

  1. Client calls /mcp with no token, gets 401 with WWW-Authenticate: Bearer ..., resource_metadata="…".

  2. Client follows that to /.well-known/oauth-protected-resource/mcp and learns which authorization server issues valid tokens.

  3. Client authenticates against Keycloak directly, using its metadata document URL as client_id. Keycloak fetches that URL and materialises the client.

  4. Client retries /mcp with the bearer token.

RFC 9728 §3.1 puts the well-known segment between host and resource path, so the discovery document lives at /.well-known/oauth-protected-resource/mcp. The bare path is a 404.

Tools

  • whoami()sub, preferred_username, azp, scope, exp, iss from the caller's token, plus registered_via_cimd. Reads the token only; no Keycloak call, so it works even if the admin credentials are wrong.

  • list_clients(only_cimd=False) — realm clients, URL-shaped client_ids sorted to the top so the calling client lands on the first line.

  • get_client_metadata(client_id) — Keycloak's own representation of a client. This is Keycloak's derived view, not the published document.

CIMD is detected by client_id starting with http:// or https://. A URL-shaped client_id is the tell.

get_client_metadata strips secret and registrationAccessToken before returning. Both are live bearer credentials, and this output goes on a projector and into a recording.

Two known gaps

RFC 8707 resource indicators. audience= is present but commented out on JWTVerifier. Audience-restricting the token would require the client to send resource on /authorize, and Keycloak 26.7's CIMD path does not honour it yet (keycloak#45106, keycloak#45284). So the token carries Keycloak's usual account audience and this server cannot audience-restrict. Visible on purpose.

No scope enforcement. required_scopes is unset, so any signature-valid, unexpired token from the realm is accepted — including one from an admin-cli password grant. That is useful on stage: run whoami() with an admin token (azp is an opaque string) and again with the CIMD token (azp is a URL), same tool, same server, the difference in one field. It also means you must be certain which token you are holding.

Configuration

All os.getenv with localhost defaults.

Variable

Default

KEYCLOAK_BASE_URL

http://localhost:8080/auth

KEYCLOAK_REALM

cimd-demo

KEYCLOAK_ISSUER

{base}/realms/{realm}

KEYCLOAK_JWKS_URI

{issuer}/protocol/openid-connect/certs

MCP_BASE_URL

http://localhost:9001

PORT

9001

KEYCLOAK_ADMIN

admin

KEYCLOAK_ADMIN_PASSWORD

admin

KEYCLOAK_ISSUER and KEYCLOAK_JWKS_URI are separately configurable rather than derived from each other: in Kubernetes the issuer is the public URL, so iss matches what clients see, while the JWKS fetch should go to the in-cluster Service.

fastmcp is pinned exactly. The auth surface moves — resource_server_url became base_url, AccessToken.claims arrived in 2.11.3 — and allowed_client_redirect_uris is not a RemoteAuthProvider parameter in any release, despite what the docs show. It belongs to OAuthProxy, which this server does not use.

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/atulram/keycloak-realm-inspector'

If you have feedback or need assistance with the MCP directory API, please join our Discord server