Skip to content

Identity provider runbook

How to point the gateway at each supported identity provider, what to configure on the provider’s side, and the claim-shape gotchas the profile already handles (plan §3.9, WP C.1b). There is one validator; a profile is the set of defaults and corrections that makes a provider’s tokens readable by it. Everything a profile decides can be overridden with the MCP_OIDC_* settings in configuration.md.

Common to every provider:

  • MCP_AUTH_MODE=oidc, MCP_OIDC_PROFILE, MCP_OIDC_ISSUER, and MCP_OIDC_AUDIENCE are the whole identity configuration in the common case. MCP_AUTH_MODE=okta with MCP_OKTA_* is the Okta profile under its original name; do not mix the two families.
  • The token must be an RS256 JWT access token with a kid. An opaque access token, an ID token, or an HMAC-signed token is refused.
  • The subject the profile names is what policy subjects, the revocation list, session binding, and every audit record key on. Choose it once; changing it later re-keys every existing revocation entry.
  • The principal’s authority in the audit journal is the issuer URL, so a record says which provider vouched without the format naming a vendor.
  • Every principal is stamped with MCP_TENANT, defaulting to the issuer’s host. Set it explicitly where the host is a shared one (login.microsoftonline.com, <tenant>.auth0.com are fine; a shared Keycloak host serving several realms is not).
  • Rotation: the gateway refetches keys on an unknown kid (one fetch per 30 s) and every 10 minutes in the background; a withdrawn key stops vouching for cached tokens at the next fetch. Rotate at the provider; nothing needs restarting. revocation-runbook.md covers a compromised key.

Register a separate public client using authorization code with S256 PKCE and no client secret. The callback is exactly MCP_PUBLIC_URL without its trailing slash plus MCP_CONSOLE_REDIRECT_PATH (default /console/callback). Set its id as MCP_CONSOLE_CLIENT_ID. The console exchanges the code server-side and retains only the access token; it does not use an IdP SDK, consume an ID token, or refresh tokens. See the console runbook for the Strict-cookie landing page.

Evidence boundary: every console registration section below is unproven against a real tenant, including Okta and Keycloak. The existing validator fixtures/live tests and wiremock console tests do not prove interactive client registration. CF-38 (huskercane/mcp-server-devtools#128) tracks a successful browser login and admin call per provider, including Entra’s scope and code-redemption compatibility. Do not infer production interoperability from ADR-013’s acceptance.

Service accounts and the administrative API

Section titled “Service accounts and the administrative API”

A client-credentials token from the configured issuer authenticates like any other (see configuration.md, “Service accounts already authenticate”). What differs is the administrative boundary: /admin/* requires mcp:admin and a principal kind MCP_ADMIN_PRINCIPALS admits. By default a token the provider positively marks as a machine’s is refused there with 403 machine_principal, because the two-person approval rule compares subjects, and two service accounts with mcp:admin would satisfy it with nobody in the loop (admin API runbook).

The kind is read from the claims each provider documents for the purpose, never from a subject-name convention (service-account-…, …@clients are display strings an administrator can change). MCP_OIDC_MACHINE_CLAIM and MCP_OIDC_HUMAN_CLAIM add a marker where the profile’s is missing or the provider is configured unusually.

Profile Machine when Person when Provider-side setup
okta cid present and no uid uid present None: Okta omits uid “if there is no user bound to” the token. Both directions marked, so MCP_ADMIN_PRINCIPALS=human is safe here.
entra idtyp = app idtyp = user Add the idtyp optional claim to the resource app registration’s access tokens; add the include_user_token property for user tokens to carry user. Without it every token is unknown, and the default admits it.
auth0 gty = client-credentials — None on the default token profile. The RFC 9068 profile omits gty; set MCP_OIDC_MACHINE_CLAIM to a claim an Action adds for machine clients.
keycloak any of clientId (client_id from Keycloak 25), clientHost, clientAddress present — Verify the emitted claims. Earlier Keycloak 26.5.7 fixture runs emitted no marker and were classified as unknown; the PR 2 live run emitted client_id, clientHost and clientAddress before the explicit mapper was added. Version alone does not establish a realm’s mapper configuration. Add a User Session Note mapper to the service-account client (or a scope every such client has): session note clientId, token claim client_id, Add to access token on. The note exists only on a service-account login, so a person through the same client carries no client_id (also observed).
generic MCP_OIDC_MACHINE_CLAIM only MCP_OIDC_HUMAN_CLAIM only The specification has no such claim. Without a marker the gateway logs at startup that /admin/* admits every mcp:admin token, a service account’s included.

The kind is a fact about the token and is not part of the principal, so the audit record shape is unchanged; a refused machine principal is logged by category and is never entered in the observed-principal inventory. Access review pages do not yet render the kind (CF-42). Local fixtures prove the gateway’s reading of each claim shape; they do not prove that a given tenant emits it — record that per provider before relying on it.

Setting Value
MCP_OIDC_PROFILE okta (or MCP_AUTH_MODE=okta with MCP_OKTA_*)
MCP_OIDC_ISSUER https://<org>.okta.com/oauth2/<authorization server id> — a custom authorization server, not the org server
MCP_OIDC_AUDIENCE the authorization server’s Audience setting
Keys {issuer}/v1/keys
Scopes scp array
Subject sub
Groups groups

On the Okta side: a custom authorization server with the audience above; an access policy that grants mcp:tools (or your MCP_REQUIRED_SCOPE) to the client; a groups claim on the access token (Security → API → Authorization Servers → Claims → groups, value type Groups, filter as needed — a filter of .* sends every group). Tokens from the org server (https://<org>.okta.com) are not verifiable with published keys; the issuer must be a custom server.

MCP client discovery and registration troubleshooting — Okta

Section titled “MCP client discovery and registration troubleshooting — Okta”

MCP_PUBLIC_URL names the MCP service’s reachable base URL, not the Okta tenant. Check the unauthenticated /mcp response’s WWW-Authenticate challenge: its metadata URL must point to this service, and the advertised protected-resource metadata must be reachable by the client.

Check provider discovery for the canonical issuer and signing-key location. A custom Okta domain can make the canonical issuer differ from the initial tenant hostname. Discovery can advertise scopes but does not establish the API audience: obtain that from the authorization server configuration or an access token intended for this MCP resource. An Okta dashboard/internal token or an ID token does not establish the MCP audience.

If the client reaches dynamic client registration and receives 403 Invalid session, record the client-side error separately from server logs. It proves registration was attempted, not that a token was issued or accepted. Arrange an approved client registration and configure its client ID in a client that supports preconfigured OAuth credentials. Verify Authorization Code with PKCE, the exact callback, user assignment, allowed scopes and API audience with the IdP administrator. A 404 shown by the client may be a separate attempt; do not infer the IdP failure’s cause from that status alone.

The 2026-10-09 Claude Code experiment reached metadata (200), provider discovery and registration rejection, with no successful authenticated MCP call. Its evidence and remaining limitations are recorded in the release operations runbook. Console sign-in requires its own browser callback and authenticated admin-call evidence; an MCP client’s discovery flow does not satisfy that requirement.

Create an OIDC Native Application or Single-Page Application, with Authorization Code enabled and client authentication None (PKCE). Register the exact HTTPS callback and assign the administrators who may sign in. On the custom authorization server, grant this client mcp:admin through its scopes and access policy; keep MCP_CONSOLE_SCOPES="openid mcp:admin". Its Audience must match the gateway’s configured audience. The console sends no audience or resource parameter for Okta: the custom authorization server selects it. See Okta’s PKCE registration guide.

Setting Value
MCP_OIDC_PROFILE entra
MCP_OIDC_ISSUER https://login.microsoftonline.com/<tenant id>/v2.0 for v2 tokens; https://sts.windows.net/<tenant id>/ for v1
MCP_OIDC_AUDIENCE the app registration’s Application ID URI (api://<client id>) or, for v2 tokens, the client id
Keys discovery: {issuer}/.well-known/openid-configuration → jwks_uri
Scopes scp, a space-separated string
Subject oid
Groups groups; or roles for app roles

On the Entra side: an app registration exposing an API scope (so tokens carry your audience rather than Graph’s); accessTokenAcceptedVersion: 2 in the manifest if the issuer above is the v2 one (the token’s iss and ver must agree with the configured issuer — a v1 token against the v2 issuer is wrong_issuer); Token configuration → Add groups claim for group membership. Use a specific tenant id: the common and organizations discovery endpoints return a templated issuer, which the gateway refuses to take keys from.

Gotchas the profile handles:

  • sub is pairwise per application. The same person has a different sub in every app, so the profile binds to oid, the directory object id. Setting MCP_OIDC_SUBJECT_CLAIM=sub is possible but means a revocation entry written from one app’s tokens does not match another’s.
  • Groups overage. Past roughly 200 groups (150 for SAML, 6 for implicit flow) the token carries no groups claim and instead _claim_names / _claim_sources pointing at Graph. The gateway does not follow that pointer; the token is refused as groups_overage. Fix it at the source: restrict the groups claim to groups assigned to the application, or use app roles and set MCP_OIDC_GROUPS_CLAIM=roles. A user in no groups is not an overage and is accepted with none.
  • Group values are object ids (GUIDs) unless the claim is configured to emit names (cloud-only groups can emit sAMAccountName or display name for synced groups). Policy subjects.groups must match what the token carries; mcp-devtools policy explain --group <value> shows the effect.

In the resource API registration, expose a delegated mcp:admin scope. Create a separate console registration with a Single-page application (SPA) redirect URI equal to the callback. Grant its delegated API permission and the required consent. Set MCP_CONSOLE_CLIENT_ID to the console application’s id. Request the resource’s fully qualified scope:

MCP_CONSOLE_SCOPES=openid api://<resource-app-id>/mcp:admin

Use the resource’s actual Application ID URI if it differs from api://<resource-app-id>. The prefix selects the API; the access token’s scp must include the unprefixed mcp:admin. Configure audience for the resource API and issuer for the tenant/token version, not the console app or Graph. The console sends neither resource nor audience for Entra.

Known compatibility gap, not a proven recipe: Microsoft requires an Origin header when redeeming a code for a SPA redirect URI. D.1 redeems it server-side without that header, so this registration is expected to fail at token exchange. The prefixed scope form is also unproven against a real tenant. Resolve and test the registration/exchange design before deploying this profile; acceptance of ADR-013 does not close this evidence gap. See Microsoft’s authorization-code flow and CF-38. No client-secret workaround is implemented.

Setting Value
MCP_OIDC_PROFILE keycloak
MCP_OIDC_ISSUER https://<host>/realms/<realm>
MCP_OIDC_AUDIENCE the value the client’s audience mapper adds
Keys {issuer}/protocol/openid-connect/certs
Scopes scope, a space-separated string
Subject sub (the user id)
Groups groups from a Group Membership mapper; or realm_access.roles

On the Keycloak side, on the client the tokens are issued to:

  1. An audience mapper is not optional. A Keycloak access token carries no aud of yours by default (Keycloak 26 emits none at all; older realms add account), and the gateway refuses it as missing_claim / wrong_audience rather than accept azp in its place — azp names who asked for the token, not who it is for. Add a Mappers → By configuration → Audience mapper with Included Custom Audience = MCP_OIDC_AUDIENCE, Add to access token on.
  2. A Group Membership mapper (By configuration → Group Membership) with Token Claim Name groups, Add to access token on. With Full group path on, values are /engineering/platform; the profile removes the leading slash, so policy matches engineering/platform. With it off, values are the leaf names.
  3. Realm or client roles instead of groups: set MCP_OIDC_GROUPS_CLAIM=realm_access.roles (or resource_access.<client>.roles); the setting is a path into the nested claim.
  4. Service accounts need a verified marker. Check the actual access token from each registration (see service accounts). Earlier Keycloak 26.5.7 fixtures lacked markers; the PR 2 live fixture emitted them by default. If absent, add a User Session Note mapper with session note clientId and token claim client_id to every client that has Service accounts roles on, or /admin/* cannot tell it from a person. Related, observed on the same version: a user access token carries sub only through the basic client scope (or a Subject (sub) mapper); a realm imported without the built-in scopes issues user tokens with no sub, refused as missing_claim.

The realm used by CI is tests/fixtures/keycloak/mcp-realm.json — two clients, one with the audience mapper and one without, which is what the live test uses to prove the refusal. To run it locally:

Terminal window
docker run -d --name keycloak -p 127.0.0.1:8080:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
-v "$PWD/tests/fixtures/keycloak:/opt/keycloak/data/import:ro" \
quay.io/keycloak/keycloak:26.5.7 start-dev --import-realm
MCP_KEYCLOAK_URL=http://127.0.0.1:8080 cargo test --test keycloak_live_tests -- --nocapture

The test rotates the realm’s signing key through the admin API and withdraws the old one, so it is also the worked example of what rotation looks like from the gateway’s side.

Create an OpenID Connect public client (Client authentication off), Standard flow on, with PKCE method S256 and the exact HTTPS callback as a Valid Redirect URI. Assign mcp:admin as a client scope included in the access token’s scope, and configure the audience/group mappers described above. Set MCP_CONSOLE_CLIENT_ID to that client and leave MCP_CONSOLE_SCOPES="openid mcp:admin" unless additional client scopes are needed. See Keycloak’s client configuration guide.

D.1 sends resource=<configured audience> in both authorization and token requests, using RFC 8707. That is a description of the console’s request, not proof that the target Keycloak version honors resource indicators. Keep the audience mapper: resource does not itself establish that the resulting JWT has the right aud. Prove the complete browser flow against the target realm (CF-38).

Setting Value
MCP_OIDC_PROFILE auth0
MCP_OIDC_ISSUER https://<tenant>.<region>.auth0.com (or a custom domain); the token’s iss ends in /, and either spelling is accepted
MCP_OIDC_AUDIENCE the API’s Identifier
Keys {issuer}/.well-known/jwks.json
Scopes scope, a space-separated string
Subject sub (auth0|…, google-oauth2|… — the connection is part of it)
Groups none by default; set MCP_OIDC_GROUPS_CLAIM to the custom claim

On the Auth0 side: register an API with the identifier above and require clients to request it as audience — without an API audience Auth0 issues an opaque access token, which is refused as malformed. Auth0 puts nothing group-like in an access token by itself; add a post-login Action that sets a namespaced claim, for example api.accessToken.setCustomClaim('https://acme.example/groups', event.user.app_metadata.groups), and set MCP_OIDC_GROUPS_CLAIM=https://acme.example/groups (the name is looked up literally, dots and slashes included). aud is an array — the API identifier plus the userinfo URL — which is fine.

Create a Single Page Application with the exact callback in Allowed Callback URLs, Authorization Code enabled, and no token endpoint client secret. Register the API and mcp:admin permission and grant access/consent to the administrators. Set MCP_CONSOLE_CLIENT_ID to the SPA client id and MCP_CONSOLE_SCOPES="openid mcp:admin". D.1 adds audience equal to MCP_OIDC_AUDIENCE to the authorization URL; this must be the API Identifier. The token exchange uses the code and verifier. See Auth0 authorization parameters. Confirm the server-side public-client redemption against the tenant; this console flow has only wiremock evidence (CF-38).

Setting Value
MCP_OIDC_PROFILE generic
MCP_OIDC_ISSUER the issuer of the provider’s discovery document
Keys discovery: {issuer}/.well-known/openid-configuration → jwks_uri
Scopes scp or scope, string or array
Subject sub
Groups groups, values as they come

This is the spec shape with no corrections. If the provider’s tokens need one that a setting cannot express, that is a new profile: a row in Profile in src/auth/oidc.rs, a fixture-locked test in tests/token_validator_tests.rs, and a section here.

Register a public authorization-code client with S256 PKCE, no secret, the exact HTTPS callback, and permission for openid mcp:admin. Configure MCP_CONSOLE_CLIENT_ID and any additional scopes in MCP_CONSOLE_SCOPES. Discovery must advertise authorization and token endpoints for the same issuer. D.1 sends the configured audience as RFC 8707 resource at both endpoints. Check the provider supports that parameter and server-side public-client code redemption, then verify that its RS256 access token passes the configured issuer, audience, subject and scope checks through GET /admin/policy. A provider requiring a secret or a different audience parameter needs an explicit adapter change; generic is not automatic interoperability. Real-provider proof remains CF-38.

The WWW-Authenticate challenge and the operator log name a category, never a claim value.

Category Usual cause
keys_unavailable (503) The JWKS or discovery URL is unreachable, answers non-2xx, is too large, or the discovery document names a different issuer than configured.
unknown_key The token’s kid is not in the provider’s key set: a rotation the gateway has not fetched yet (it fetches on the miss, once per 30 s), or a token from a different issuer / realm / authorization server.
wrong_issuer iss differs beyond a trailing slash: wrong tenant, realm, or authorization server; Entra v1 token against a v2 issuer.
wrong_audience, missing_claim (aud) No audience mapper (Keycloak), no API audience requested (Auth0), Graph’s audience instead of yours (Entra).
missing_claim (oid, sub, …) The subject claim the profile names is absent — usually a client-credentials token with no user, or an ID token sent as an access token.
machine_principal (403, /admin/* only) The token validated and carries mcp:admin, but the provider marks it as a machine’s and MCP_ADMIN_PRINCIPALS refuses those. Administer as a person, or see the setting before opening administration to automation.
unverified_principal_kind (403, /admin/* only) MCP_ADMIN_PRINCIPALS=human and the token carries no positive person marker — the profile has none (Keycloak, Auth0, generic), or Entra’s idtyp is not emitted for user tokens. Configure the marker, or use the default.
groups_overage Entra left the groups out; see the Entra section.
unsupported_algorithm, malformed Not an RS256 JWT: an opaque token, an HMAC token, alg: none.
not_yet_valid, expired Clock skew beyond MCP_OIDC_CLOCK_SKEW_SECONDS; fix the clock, do not widen the skew.

MCP_CLIENT_CREDENTIALS_ENABLED=true advertises io.modelcontextprotocol/oauth-client-credentials: {} only in authenticated HTTP server/discover. It defaults to false, accepts only literal true or false, and requires inbound OIDC on HTTP. Stdio and direct CLI commands ignore it. Discovery follows global-config reloads; invalid or conflicting values disable advertisement and log a warning. The console lists this setting as read-only. EMA can be enabled independently; legacy initialize responses and ordinary authenticated clients are unchanged. This flag does not enable Tasks.

This flag declares support, not a machine-only admission policy. An access token need not identify the grant that acquired it. The existing issuer, audience, signature, expiry, scope, revocation, rate-limit and tool-policy checks apply with the flag on or off. Machine classification still comes from validated provider claims. The MCP client registers with the external authorization server and acquires/renews its tokens there; the MCP server never receives its client secret or private-key JWT assertion.

For a dedicated Keycloak automation client:

  1. Create a confidential OpenID Connect client with client authentication and service accounts enabled. Disable Standard flow and Direct access grants for this automation-only client. Keep Full scope allowed off, give its service account only the roles it needs, and give it no administrative realm roles or mcp:admin scope.

  2. Add an audience mapper for api://mcp-devtools in access tokens, a Subject mapper (or the basic scope) for stable sub, and the User Session Note mapper clientId → client_id described above. Never classify by the service-account-… name or substitute client ID for the validated subject.

  3. Create the mcp:tools client scope with Include in token scope enabled and assign it to this client. Limit assigned scopes to those needed by the automation. Verify the issued token’s audience, scope and machine marker through the gateway; decoding a JWT alone is not validation.

  4. Configure the gateway with MCP_AUTH_MODE=oidc, MCP_OIDC_PROFILE=keycloak, the realm’s MCP_OIDC_ISSUER, MCP_OIDC_AUDIENCE=api://mcp-devtools, MCP_REQUIRED_SCOPE=mcp:tools, MCP_CLIENT_CREDENTIALS_ENABLED=true, and the existing HTTPS public URL, signed policy, revocation list, authorization generation and durable audit prerequisites. Keep MCP_ADMIN_PRINCIPALS=human-or-unknown, or use human when every administrator has a positive human marker. Never use any to let two service accounts propose and approve each other’s changes.

  5. Sign a default-deny policy with an exact service-account subject and only the resources/actions needed. For example, this rule permits one Jira issue read and denies other issues and all writes:

    version: 1
    default: deny
    rules:
    - id: service-read-plat
    effect: allow
    subjects: { subjects: ["<validated-service-account-sub>"] }
    match:
    vendor: jira
    normalized_action: read_issue
    resource_type: issue
    resource_id: ["PLAT-1"]

    Validate with mcp-devtools policy check <file> and deploy through the existing signed-policy procedure. Use a separately restricted upstream vendor credential as well; inbound OAuth credentials are never forwarded.

  6. The MCP client reads the realm’s discovery metadata and uses a registered, advertised token-endpoint authentication method. The local live test uses client_secret_post, verifies that method is advertised, and sends grant_type=client_credentials, client_id and client_secret to the token endpoint over the client–AS boundary. Production uses HTTPS and a client-side secret store. If using private_key_jwt, keep grant_type=client_credentials and send the registered signed client_assertion and its assertion type; a JWT-bearer grant is a different flow. This change does not establish private-key JWT interoperability.

  7. Present only the resulting access token as the HTTP Bearer credential. Verify discovery, an allowed jira_get for /rest/api/3/issue/PLAT-1, and a denied request for another issue. Renew by acquiring another access token before expiry; do not assume a refresh token is issued for this grant. Test client-secret/key rotation separately for the chosen provider/client deployment; evidence remains tracked under #132.

keycloak_live_tests creates a disposable service account and scope, checks the real claim mapping, and uses that token through the production HTTP middleware and policy admission for allowed and denied Jira calls. Jira is a local HTTP fixture: this proves AS-to-gateway interoperability, not a real Jira deployment or a named MCP client’s acquisition/renewal implementation. The live fixture temporarily enables Direct access grants solely to prove that a person using the same client is not misclassified as a machine; the production automation registration above should leave it disabled.