MCP authorization with the optional mcp-auth server¶
MCP OAuth authorization is optional. An MCP server enables it by including
spec.auth; omitting spec.auth leaves OAuth disabled. The bundled
mcp-auth-server is also optional because a deployment may use an external
OAuth authorization server instead.
What each component does¶
There are three separate responsibilities:
MCP client → optional mcp-auth-server → Keycloak/OIDC provider
→ MCP access token → Runtime gateway → policy/governance → MCP server
- The bundled
mcp-auth-serveris the OAuth authorization server. It logs the user in through one configured identity provider and mints resource-bound MCP tokens. - Keycloak is an external identity provider. It owns the realm, users, and upstream OIDC client credentials.
- The Runtime gateway is the protected-resource boundary. It validates the token and applies grants, agent sessions, trust, and per-tool policy before forwarding the call.
The gateway is the policy decision point because governance must inspect the actual MCP JSON-RPC tool call and current grant/session state. The authorization server only sees login and token requests.
Each MCP server can declare its allowed OAuth scopes in its .mcp/servers.yaml
manifest. The selected identity-provider connector supplies login and claims;
it does not need to define the MCP server's scopes:
auth:
issuerURL: https://auth.example.com/mcp-auth
audience: https://mcp.example.com/cully/mcp
scopes: [tools:read, tools:write]
The operator publishes this scope list to the bundled authorization server
for that exact resource. MCP Auth rejects requests for scopes outside the
resource list and advertises the list in protected-resource metadata. The
Runtime gateway also advertises the server's scopes in its public resource
metadata and checks token scope and per-call policy when a tool is
invoked. Servers without auth.scopes receive MCP Auth's read-only default;
set auth.scopes in the server manifest to grant write access explicitly.
Responsibility boundary¶
Runtime ships and can deploy the optional mcp-auth-server. You operate the
identity provider. You are responsible for operating Keycloak,
Okta, PingOne, Entra ID, Auth0, or another OIDC/OAuth provider, including its
realm or tenant, users, client registration, client secret, redirect URI,
claims, scopes, availability, backups, and certificate/DNS configuration.
Runtime is responsible for loading the connector, validating its required configuration, exposing MCP authorization metadata, and keeping the provider secret in Kubernetes. The mcp-auth server authenticates through that provider and mints MCP tokens; Runtime remains the MCP resource server and governance decision point.
Before enabling the feature, confirm that the provider exposes HTTPS OIDC discovery, has a confidential client with authorization-code flow and S256 PKCE enabled, has the exact callback URI registered, and includes a stable subject claim. Runtime cannot repair an incorrect realm, client registration, redirect URI, claim mapping, or provider outage.
Public hostnames and TLS¶
For a public k3s deployment, create DNS A records pointing to the ingress node:
keycloak.example.com → <public ingress IP>
auth.example.com → <public ingress IP>
Issue certificates for both names with the cluster's ACME issuer. Setup creates
the auth-server Certificate and its Secret in the mcp-platform namespace;
--mcp-auth-tls-secret is only needed when certificates are externally managed.
Keycloak's certificate can be named keycloak-tls.
The platform hostname must also be present in the platform UI Ingress before opening it in a browser. A browser with HSTS will not offer a certificate exception: if DNS is missing, the Ingress is absent, or cert-manager has not issued the certificate, Firefox may show a security error or Traefik may serve its default certificate. Check these before debugging login:
dig +short platform.example.com
kubectl get ingress -n mcp-platform mcp-platform-ui
kubectl get certificate -n mcp-platform
kubectl describe certificate -n mcp-platform <platform-certificate>
Wait for the Certificate condition to become Ready=True, and verify that
the certificate SAN contains the exact platform hostname. Do not work around
this with an HTTP URL or a browser exception.
Do not use an HTTP issuer, an IP address, or a self-signed public certificate outside local test mode.
If Traefik connects to Keycloak over HTTPS, configure a Traefik
ServersTransport with serverName set to the Keycloak hostname. Otherwise
the backend certificate is checked against the pod IP and discovery fails with
x509: ... certificate ... doesn't contain any IP SANs. Do not disable TLS
verification in production.
Configure Keycloak¶
Deploy Keycloak separately, then create:
- Realm:
mcp-runtime. - Confidential client:
mcp-auth. - Standard authorization-code flow enabled; direct password grants disabled.
- Exact redirect URI:
https://auth.example.com/mcp-auth/identity/callback
The resulting OIDC issuer is:
https://keycloak.example.com/realms/mcp-runtime
Create a test user in the realm. Keep the client secret in a secret manager or environment variable; never place it in the connector JSON or Git.
Run the Runtime-side provider preflight before setup:
./bin/mcp-runtime auth provider-check \
--issuer-url https://keycloak.example.com/realms/mcp-runtime
This checks discovery of the issuer, authorization endpoint, token endpoint, and JWKS URI without sending client credentials. It does not prove the client secret, callback, user login, claims, or scopes; complete those provider-side checks with a dedicated non-admin test account.
Write the connector file¶
The connector file holds provider configuration. The
client_secret_env value names the environment variable that setup reads and
stores in the Kubernetes Secret mcp-auth-connector-secrets.
Every referenced environment variable must be exported in the same shell that starts setup. Setup fails before applying the auth server if a referenced secret is unset, so a partially configured connector is never deployed. Read the value from a protected file or secret manager, and keep it out of the connector JSON:
export KEYCLOAK_CLIENT_SECRET="$(tr -d '\n' < /secure/keycloak-client-secret)"
{
"keycloak": {
"issuer": "https://keycloak.example.com/realms/mcp-runtime",
"authorization_endpoint": "https://keycloak.example.com/realms/mcp-runtime/protocol/openid-connect/auth",
"token_endpoint": "https://keycloak.example.com/realms/mcp-runtime/protocol/openid-connect/token",
"jwks_uri": "https://keycloak.example.com/realms/mcp-runtime/protocol/openid-connect/certs",
"token_endpoint_internal": "https://keycloak.mcp-platform.svc.cluster.local:8443/realms/mcp-runtime/protocol/openid-connect/token",
"jwks_uri_internal": "https://keycloak.mcp-platform.svc.cluster.local:8443/realms/mcp-runtime/protocol/openid-connect/certs",
"token_endpoint_server_name": "keycloak.example.com",
"client_id": "mcp-auth",
"client_secret_env": "KEYCLOAK_CLIENT_SECRET",
"exchange_client_id": "mcp-auth",
"scopes": ["openid", "profile", "email"],
"identity_claims": ["preferred_username"],
"token_endpoint_auth_method": "client_secret_post",
"allowed_upstream_callback_uris": [
"https://auth.example.com/mcp-auth/identity/callback"
],
"downstream_token_strategy": "upstream_session"
}
}
All four provider endpoints must use HTTPS in production. Explicit endpoints
are useful when the auth pod cannot hairpin through the public ingress. For an
in-cluster provider, keep the public token_endpoint for discovery and add
token_endpoint_internal with the private HTTPS Service URL plus
token_endpoint_server_name matching the provider certificate. If an internal
endpoint is used, it must still be HTTPS and use a certificate trusted by the
auth server; an internal HTTP shortcut is test-only.
Configure the MCPServer resource¶
To enable OAuth on a governed MCPServer, add spec.auth and choose one
canonical resource URI:
spec:
auth:
# Defaults from the bundled issuer configured on the operator.
audience: https://mcp.example.com/my-server/mcp
The audience must equal the MCP server's canonical public URL. The operator
keeps the bundled mcp-auth resource list aligned with OAuth MCPServer
audiences. An optional --mcp-auth-resource-url is an initial bootstrap value
and must exactly match the corresponding spec.auth.audience. The
gateway rejects tokens with a different issuer or audience, then forwards the
validated bearer to the MCP application server. The application validates the
same issuer and audience and must not pass the token to another API.
You can omit audience. The operator then derives it from the server's public
URL: https:// when the operator runs with MCP_DEFAULT_INGRESS_TLS=true or
the ingress has the traefik.ingress.kubernetes.io/router.tls: "true"
annotation, then spec.ingressHost (or the shared MCP host resolver's
MCP_MCP_INGRESS_HOST, MCP_DEFAULT_INGRESS_HOST, or mcp.<MCP_PLATFORM_DOMAIN>),
then /<publicPathPrefix>/mcp. A path-based
server named my-server on mcp.example.com gets
https://mcp.example.com/my-server/mcp. If no host is known (for example in
local test mode), set audience explicitly.
Derived audience and issuerURL values are computed on every reconcile and
are not written back to spec. Changing the host, path, TLS setting, or
platform domain therefore changes them too; status.url shows the current
public URL.
Each public route has one owner. When two MCPServers resolve to the same path
on the same host (or one of them is path-based and matches every host), the
older one keeps the route. The later one reports an Error phase naming the
owner, gets no Ingress, and its audience is not added to the bundled
authorization server. Otherwise the two servers would accept each other's
tokens.
The operator owns MCP_PATH for every server and derives it from the public
ingress route. When the gateway sets gateway.stripPrefix, it is the route
with that prefix removed, because that is the path the gateway forwards to the
server. Do not add it to spec.envVars or .mcp/servers.yaml; the operator
replaces any value set there.
For every OAuth server, with the gateway enabled or disabled, the operator also
injects the values the application needs to validate the bearer and publish
matching metadata: MCP_AUTH_RESOURCE (the audience),
MCP_AUTH_RESOURCE_METADATA_URL
(<origin>/.well-known/oauth-protected-resource<path>), and MCP_AUTH_ISSUER
(auth.issuerURL). The gateway forwards the original validated bearer rather
than exchanging it, so that token is valid for the same issuer and public
audience upstream. MCP_PATH can differ from the audience path when the gateway
strips a prefix; the token audience stays the public resource URL. Remove
hand-set copies of these derived values so the advertised resource stays in
step with the ingress host. The gateway challenge
and ingress metadata route use the same metadata URL derived from audience.
MCP clients reject metadata whose resource names a different origin than the
URL they connected to.
Deploy through setup¶
Create the persistent signing-key Secret with the RSA key stored as
private-key.pem, export the client secret only in the setup environment, and
run:
KEYCLOAK_CLIENT_SECRET='from-your-secret-manager' \
./bin/mcp-runtime setup \
--with-tls --tls-cluster-issuer letsencrypt-prod \
--with-mcp-auth-server \
--mcp-auth-signing-key-secret mcp-auth-signing-key \
--mcp-auth-connectors-file /secure/mcp-auth-connectors.json \
--mcp-auth-connector keycloak
The TLS certificate for the issuer host is provisioned by setup using the
configured TLS ClusterIssuer. Pass --mcp-auth-tls-secret only when the
certificate is externally managed (and use --provided-tls-secrets).
The default image is docker.io/princekrroshan01/mcp-auth-server:0.4.4.
The server uses SQLite on a PVC in production and memory storage only in
--test-mode. The setup flag is opt-in; when it is absent, Runtime does not
deploy this authorization server.
The mcp-auth server image is released independently from the MCP Runtime CLI
and platform images. For a public production rollout, use a unique image ref
such as registry.mcpruntime.org/mcp-auth-server:<tag> and update it through
the reference deployment guide.
An image-only update preserves the existing connector configuration, SQLite
PVC, signing key, and TLS Secret; it does not require rerunning setup or
issuing a certificate.
Client ID Metadata Documents (CIMD) are disabled by default in mcp-auth. To
test CIMD, deploy an image containing the feature and set
MCP_AUTH_CLIENT_ID_METADATA_ENABLED=true; then confirm the authorization
server metadata advertises client_id_metadata_document_supported: true.
MCP_AUTH_CLIENT_ID_METADATA_HOSTS can restrict fetched metadata documents to
a comma-separated host allowlist. When unset, mcp-auth still rejects non-HTTPS
URLs and blocks private/reserved network destinations during DNS resolution.
After enabling CIMD, clear a client's cached OAuth credentials before
reconnecting so it does not reuse a previously registered DCR client ID.
The same values can be supplied through the public deployment environment:
export MCP_SETUP_WITH_MCP_AUTH_SERVER=1
# Issuer defaults to https://auth.<MCP_PLATFORM_DOMAIN>/mcp-auth.
# Resource audiences are reconciled from OAuth MCPServer objects.
# Optional only for externally managed TLS (required with provided-tls-secrets).
export MCP_SETUP_MCP_AUTH_TLS_SECRET=mcp-auth-server-tls
export MCP_SETUP_MCP_AUTH_SIGNING_KEY_SECRET=mcp-auth-signing-key
export MCP_SETUP_MCP_AUTH_CONNECTORS_FILE=/secure/mcp-auth-connectors.json
export MCP_SETUP_MCP_AUTH_CONNECTOR=keycloak
Verify the flow¶
Check that the authorization server and Keycloak publish metadata. For a
path-mounted issuer such as https://auth.example.com/mcp-auth, RFC 8414
inserts the well-known segment before the issuer path, so discovery is served
at https://auth.example.com/.well-known/oauth-authorization-server/mcp-auth,
not under the issuer URL:
curl -fsS https://auth.example.com/.well-known/oauth-authorization-server/mcp-auth
curl -fsS https://keycloak.example.com/realms/mcp-runtime/.well-known/openid-configuration
kubectl -n mcp-platform rollout status deploy/mcp-auth-server
Then use an MCP client or the Go example's OAuth metadata (examples/oauth-example-go-2025-11-25/.mcp/servers.yaml)
to run the real PKCE flow.
Verify all of these outcomes:
- no token returns
401and a properWWW-Authenticatechallenge; - a valid token reaches the Runtime gateway;
- a granted tool succeeds and produces an audit event;
- an ungranted tool returns
403and does not reach the upstream server; - changing or revoking a Runtime grant takes effect without issuing a new IdP token.
Local test mode¶
For Kind-only testing:
./bin/mcp-runtime setup --test-mode --with-mcp-auth-server \
--ingress-manifest config/ingress/overlays/http
Test mode may use the loopback issuer, in-memory storage, generated signing keys, and the local development token exchange. It does not prove production OIDC federation. Use the Keycloak connector and HTTPS settings above for a real provider test.
Other identity providers¶
The connector is provider-neutral. The bundled auth server loads a named connector from the JSON file at startup; the gateway contains no Okta, PingOne, Auth0, Microsoft Entra ID, Google, or other provider-specific adapter:
MCP_AUTH_CONNECTORS_FILE → MCP_AUTH_CONNECTOR → IdentityProvider/TokenExchanger
One auth-server process selects one connector and one MCP resource. A file may
contain several named connectors for different environments, but changing the
selected provider requires changing --mcp-auth-connector and restarting or
rolling out the deployment. This keeps provider credentials and provider
specific behavior outside the Runtime gateway and MCP tool handlers.
For any OIDC provider, start with this shape and replace the issuer, client ID, and callback settings from that provider's console:
{
"oidc": {
"issuer": "https://idp.example.com/<tenant-or-realm>",
"client_id": "mcp-auth",
"client_secret_env": "OIDC_CLIENT_SECRET",
"exchange_client_id": "mcp-auth",
"scopes": ["openid", "profile", "email"],
"identity_claims": ["sub", "email"],
"token_endpoint_auth_method": "client_secret_post",
"allowed_upstream_callback_uris": [
"https://auth.example.com/mcp-auth/identity/callback"
],
"downstream_token_strategy": "upstream_session"
}
}
OIDC discovery fills in authorization, token, and JWKS endpoints when they are
omitted. Explicitly configure them when the browser-facing provider hostname
and the auth pod's back-channel hostname differ. Request openid for OIDC and
use a stable claim such as sub or preferred_username; plain OAuth 2.0
providers need a userinfo_endpoint and an identity claim from that response.
Typical issuer patterns are:
| Provider | Typical issuer pattern | Notes |
|---|---|---|
| Okta | https://<org>.okta.com/oauth2/<authorization-server-id> |
Register the exact /identity/callback URI and use the authorization server's discovery document. |
| PingOne | https://auth.pingone.<region>/<environment-id>/as |
Use the environment's OIDC discovery URL and a confidential client. |
| Auth0 | https://<tenant>.auth0.com/ |
Enable openid profile email; set the API/resource audience according to the Auth0 tenant. |
| Microsoft Entra ID | https://login.microsoftonline.com/<tenant-id>/v2.0 |
Use a tenant-specific issuer for deterministic claim and issuer validation. |
https://accounts.google.com |
Configure the OAuth client redirect URI and request openid profile email. |
|
| Generic OIDC | provider's issuer URL |
The provider must expose discovery, authorization-code login, token, JWKS, and suitable identity claims. |
Support for optional features varies by provider. Verify discovery, callback behavior, token endpoint authentication, scopes, refresh behavior, and claims with the provider before using it in production. The mcp-auth project has provider-specific notes and a verified-provider matrix in its authorization-server guide.
Before setup, run the Runtime preflight against the provider issuer:
./bin/mcp-runtime auth provider-check \
--issuer-url https://idp.example.com/<tenant-or-realm>
This fetches /.well-known/openid-configuration without sending client
credentials and checks the issuer, authorization endpoint, token endpoint, and
JWKS URI. It does not prove client-secret validity, redirect registration, user
login, refresh behavior, or provider claim mappings; complete those with a
dedicated test user before production rollout.
Clean setup and credential preservation¶
hack/deploy/reference/reset.sh --yes --wait destructively resets MCP
Runtime namespaces and workload data. Use it for a clean reinstall, not normal
upgrades. By default it writes a 0700 backup directory under
~/.mcpruntime/backups/mcpruntime-org and backup files are 0600.
The backup preserves platform bootstrap secrets, TLS material, OIDC configuration, and auth configuration when those resources are included by the deployment version. It does not preserve tenant/user database rows, API keys, grants, sessions, registry blobs, or analytics history. Keycloak realm data is owned by the identity provider and must be exported/restored with Keycloak's realm-export tooling; a Kubernetes Secret backup is not a realm backup.
Keep the platform admin password and OIDC client secret in a password manager
or a 0600 file outside Git, and export them only for setup:
umask 077
export MCP_PLATFORM_ADMIN_EMAIL=admin@example.com
export MCP_PLATFORM_ADMIN_PASSWORD='<from-password-manager>'
export KEYCLOAK_CLIENT_SECRET='<from-password-manager>'
MCP_DEPLOY_ENV=/secure/mcpruntime-org.env \
mcp-runtime setup --env-file <profile> --expect-cluster-id <uid> --with-mcp-auth-server ...
Before cleaning, run reset.sh --dry-run and confirm its namespace list. For a
fresh provider-backed install, prepare the env file, TLS Secret, signing-key
Secret, connector file, selected connector, and provider secret first. Never
use --no-backup on production unless the loss is intentional.
After setup, verify in order: provider discovery; mcp-auth discovery and JWKS;
mcp-auth readiness; platform admin login; Runtime server catalog, sessions,
and grants; governed MCP metadata; unauthenticated 401 challenge; valid token;
allowed tool; denied tool; and grant/session revocation.
If setup stops during image publication with a Kubernetes API TLS handshake
timeout, first verify the k3s API and registry pod, then rerun the same setup
command. Image publication is idempotent. If it stops with references unset
environment variable, export the named connector secret and rerun; do not
disable connector validation.
After an installation that enables mcp-auth, run:
./bin/mcp-runtime cluster diagnostics
The doctor skips mcp-auth when it is not installed. When it is installed, it
checks the deployment rollout and the required signing-key and TLS Secret data;
use the reported kubectl rollout status or Secret/Certificate remedy before
testing OAuth.
Credentials and secret handling¶
Never put passwords, OIDC client secrets, private signing keys, bearer tokens,
or realm exports in the repository, Docker command arguments, shell history,
issue reports, or CI logs. Connector JSON contains references such as
client_secret_env; setup copies the referenced value into the Kubernetes
Secret mcp-auth-connector-secrets. Rotate provider secrets and platform admin
passwords after test deployments. Rotate the mcp-auth signing key only with an
explicit token/JWKS rollover plan because existing tokens will become invalid.
How the MCP SDK fits¶
The SDK runs at the application boundary, outside Runtime governance:
- MCP clients use the SDK's discovery, PKCE, token, and
WWW-Authenticatehelpers to obtain an MCP token from the authorization server. - A standalone MCP resource server can use the SDK's
JWTVerifierto validate issuer, JWKS signature, audience, expiry, and required scopes before invoking tools. - A governed MCP server validates the bearer in both the Runtime gateway and the SDK-backed application. They are one logical protected resource and use the same issuer and audience. Runtime applies grants, sessions, and policy; the application must not pass the token to downstream APIs.
The SDK consumes provider-neutral MCP authorization metadata and JWTs. Switching from Keycloak to Okta or PingOne changes the connector and IdP client configuration; MCP tool code stays the same. Read the mcp-auth project's auth-server architecture and auth-client SDK guide for the Python and Go APIs, verifier options, metadata discovery, and token exchange boundaries.
Troubleshooting¶
issuer and resource must use HTTPS: a public deployment has an HTTP issuer or resource; usehttps://and a valid TLS Secret.connector has non-HTTPS token_endpoint: the provider endpoint is HTTP; expose Keycloak through trusted HTTPS or use the shortcut only in local test mode.- discovery connection refused from the auth pod: do not point the pod at the node's public IP. Use a reachable, trusted HTTPS service endpoint or fix cluster egress/DNS.
audience mismatch: comparespec.auth.audiencewith the canonical MCP URL and confirm the bundled auth server resource list reflects current OAuth MCPServers.- tokens fail after restart: use a persistent RSA signing-key Secret; do not rely on the test-mode ephemeral key.
Next steps¶
Once the OAuth resource and authorization server are configured, use Client Connections to supply the bearer token together with adapter identity, and follow this guide's verification steps. Identity and Authorization explains how the OAuth subject is bound to the session human during enforcement.