MCP clients like Claude, Codex or VS Code want to connect to an MCP server without registering a client first. The MCP authorization spec does this with Client ID Metadata Documents (CIMD): the client_id is a URL to a JSON document that describes the client. Entra ID doesn't support this, so you would have to register an app registration for each MCP client. Entra also rejects the resource parameter (RFC 8707) that MCP clients send, if it doesn't match the requested scopes (AADSTS9010010).
CIMD isn't a "hacky" solution, other IdPs like Pocket ID and Auth0 already support it. If your IdP does, you don't need this bridge.
The bridge sits in front of Entra ID (or another OIDC provider) and is the authorization server for the MCP clients. For Entra it's a normal confidential client, with one app registration per MCP server. The bridge uses the resource of the client to pick the app registration and scopes of the MCP server and doesn't send it to Entra. Codes and tokens come from Entra and are passed through unchanged, so your MCP servers validate Entra tokens like before.
The bridge has no database, doesn't issue its own tokens and doesn't proxy any MCP traffic.
CIMD clients can declare either token_endpoint_auth_method: none or token_endpoint_auth_method: private_key_jwt. ChatGPT's web connector uses the latter. For private_key_jwt, the client's jwks_uri must use HTTPS, stay on the same host as its client_id, and publish an RS256 signing key. The bridge validates the assertion's signature, issuer, subject, audience and expiry. The audience may be the bridge's issuer or its token endpoint, which ChatGPT uses.
If you want to understand the flow, here's a diagram showing how the bridge interacts with the MCP client and Entra ID.
The image is ghcr.io/natrontech/entra-cimd-bridge:
docker run -p 8080:8080 -v ./config.yaml:/etc/bridge/config.yaml:ro \
-e BRIDGE_KEY_CURRENT="$(openssl rand -base64 48)" \
-e ENTRA_TENANT_ID=… -e CONTACTR_CLIENT_ID=… -e CONTACTR_CLIENT_SECRET=… \
ghcr.io/natrontech/entra-cimd-bridge:latestOr with the Helm chart. The config goes under config: in the values, and the secrets into a Secret set in existingSecret. Its keys become environment variables. The chart mounts the pod's service-account token for federated_token_file, so in Kubernetes the bridge needs no client secrets.
helm install entra-cimd-bridge oci://ghcr.io/natrontech/charts/entra-cimd-bridge --values values.yamlReplicas only share the signing keys, so you can run as many as you want.
bridge --config config.yaml --check validates the config, runs the OIDC discovery for every provider and exits.
See config.example.yaml for a full example. ${NAME} in a value is replaced with the environment variable. If the variable isn't set, the bridge doesn't start.
| Key | |
|---|---|
issuer |
Public URL of the bridge, an https origin without a path. For local development http://localhost:<port> works too |
listen, metrics_listen |
Default :8080 and :9090. /metrics has its own listener so it isn't exposed to the internet |
signing_keys |
Keys with at least 32 bytes, used for the state and the consent cookie. The first key signs, all keys verify. To rotate, add a new key at the top and remove the old one after consent_ttl |
providers.<name>.issuer |
The issuer of the IdP, loaded with OIDC discovery at startup. Providers that list code_challenge_methods_supported without S256 are refused (Entra lists nothing but supports S256) |
providers.<name>.federated_token_file |
A token the app registrations trust through a federated identity credential, like the Kubernetes service-account token the chart mounts at /var/run/secrets/azure/tokens/azure-identity-token. It's read for every token request and sent as client_assertion instead of a client secret |
clients.cimd_hosts |
Hosts that can serve client metadata documents: a host, *.domain or *. Claude uses claude.ai, Codex chatgpt.com and VS Code vscode.dev. If you remove a host, new sign-ins stop, but existing refresh tokens keep working until you revoke them in Entra |
consent_ttl |
How long the consent is remembered per client and MCP server, 0 asks every time |
resources |
One entry per MCP server with its uri, a name for the consent page, the provider, the client_id of its app registration, its client_secret if the provider has no federated_token_file, and the scopes to request |
The scope the client sends is ignored, the bridge always requests the scopes from the config.
Use the app registration of the MCP server itself (Entra allows an app to request tokens for its own API), or create a separate one that only has permissions on the MCP server's API. Then:
- Add
https://<bridge>/callbackas a Web redirect URI. - Add a federated identity credential for the bridge's service account: the issuer is your cluster's service account issuer, the subject
system:serviceaccount:<namespace>:<service-account>and the audienceapi://AzureADTokenExchange. Outside Kubernetes, create a client secret instead. - Make sure the delegated scope, e.g.
api://<client-id>/access_as_user, has admin consent andrequestedAccessTokenVersionis 2.
Add the client ID (and the secret, if you created one) to resources in the config.
The MCP server validates the Entra tokens like before (issuer, audience and scope), the bridge doesn't change anything there. It only has to tell the clients where to sign in, with two things:
-
The Protected Resource Metadata (RFC 9728) at
/.well-known/oauth-protected-resourcefollowed by the path of the MCP server, with the bridge as authorization server:{ "resource": "https://contactr.natron.ch/mcp", "authorization_servers": ["https://mcp-auth.natron.ch"], "scopes_supported": ["api://<client-id>/access_as_user"] } -
A 401 with a
WWW-Authenticateheader that points to this metadata, if a request has no valid token:WWW-Authenticate: Bearer resource_metadata="https://contactr.natron.ch/.well-known/oauth-protected-resource/mcp", scope="api://<client-id>/access_as_user"
Many MCP SDKs can already do this, so you don't need anything from this repo. For Go there is the small package resource that does both:
import "github.com/natrontech/entra-cimd-bridge/resource"
meta := &resource.Metadata{
Resource: "https://contactr.natron.ch/mcp", // exactly as in the bridge's config
AuthorizationServers: []string{"https://mcp-auth.natron.ch"},
ScopesSupported: []string{"api://<client-id>/access_as_user"},
}
mux.Handle("GET "+meta.Path(), meta) // /.well-known/oauth-protected-resource/mcp
// In front of the MCP handler
token, ok := resource.BearerToken(r)
if !ok {
meta.Challenge(w) // 401 with resource_metadata and scope
return
}
if err := verifyEntraToken(r.Context(), token); err != nil {
meta.InvalidToken(w, "The access token is invalid or expired.")
return
}make test # unit and end-to-end tests
make lint # golangci-lint 2.14 or newer
make run # runs with ./config.yaml