mcp.auth.oauth in proxy mode. You log in through the browser (Authorization Code + PKCE) and see masking, run_sql gating, and tenancy scoping applied to the identity the IdP issued, instead of a fixed token.
Full source:
scripts/local_idp/examples/marketplace/canonic.yaml. The compose file mounts canonic.docker.yaml over it inside the container, so the shipped and golden-tested example stays untouched.
Prerequisites
- Docker with Compose
- A checkout of the canonic repository (the compose file builds the daemon from the repo’s own
Dockerfile) - An MCP client that speaks OAuth 2.1, for example the MCP Inspector
Start
From the repository root:canonic was imported, and for Uvicorn’s Uvicorn running on http://0.0.0.0:7474 line from the canonic container.
Test users
The realmcanonic ships five users that mirror the five static tokens of the marketplace example:
The role and tenant reach canonic as token claims. Two protocol mappers on the
canonic-mcp client put them there: roles (all realm roles of the user) and merchant_id (a user attribute). They match claim: roles in roles.yaml and claim: merchant_id in tenancy.yaml, so no claim_mapping is needed.
Configuration
The only difference to the static-token setup is themcp.auth.oauth block:
CANONIC_OAUTH_CLIENT_SECRET is local-dev-secret in the compose file and matches the secret of the canonic-mcp client in the realm export. The daemon checks the static token map first and falls through to OAuth verification, so both mechanisms are active at once. canonic mcp status reports token, oauth-proxy.
issuer_url uses localhost:8080 on purpose, not the compose service name keycloak. Keycloak’s discovery document echoes back whatever host it was queried with, and that same document is what the browser gets redirected to for login, so the host has to resolve on the browser’s side. To make localhost:8080 reach Keycloak from inside the canonic container as well, the compose file puts it into Keycloak’s network namespace (network_mode: "service:keycloak"). This is also why port 7474 is published from the keycloak service block. base_url is unrelated to this and is the host-published address the client is redirected to for the canonic side of the OAuth flow.Try the login flow
Point the MCP client athttp://localhost:7474. The client registers itself against canonic’s own OAuth endpoints (Dynamic Client Registration), and canonic redirects the browser to Keycloak for the actual login.
- Log in as
byte-gadgets-viewer/viewerand query the marketplace metrics grouped bycustomer_email. The query is rejected with anunreachableerror, becausemerchant_viewerdeniescustomer_emailandcustomer_phoneas dimensions, and a denied dimension looks exactly like a nonexistent one.run_sqlis refused as well (run_sql: false). - Disconnect and log in as
byte-gadgets-admin/admin. The same query now works andcustomer_emailcomes back masked (ab***).run_sqlis accepted by the role but still refused withTENANT_FORBIDDEN, becausemarketplace_dbhasrls_enforced: false(see run_sql’s two gates). - Log in as
platform-ops/platform. Data is visible across both merchants, because the role istenancy_exempt: trueand the token carries nomerchant_id.
Sanity check without a browser
The static tokens from the compose file still work, which allows a quick check withcurl:
Stop
Adapting this to a real Keycloak
The realm export is a local-development artifact. Passwords, the client secret andsslRequired: none are baked in, so never use it as a starting point for anything reachable from the internet. For a real deployment, recreate these pieces in your own realm.
1
Create a confidential client
Register a client (
canonic-mcp in this example) with client authentication on, the standard flow enabled, and direct access grants, implicit flow and service accounts off. Store its secret outside of canonic.yaml and reference it through client_secret_ref.2
Set the redirect URI
Allow the daemon’s public callback,
<base_url>/*, as a valid redirect URI. base_url must be the URL clients reach the daemon under, for example https://canonic.internal.example.com, and TLS terminates at your reverse proxy.3
Add the claims your policies name
Add a
roles mapper (type “User Realm Role”) and one mapper per tenant claim (type “User Attribute”, for example merchant_id) to the client, with “Add to access token” on. Set the claim.name of each mapper to the claim used in roles.yaml and tenancy.yaml. If your IdP team requires namespaced claim names, name the mapper claim https://example.com/merchant_id and map it with claim_mapping instead.4
Point canonic at the realm
Set
issuer_url to https://<keycloak-host>/realms/<realm>. In proxy mode canonic discovers the endpoints from issuer_url + /.well-known/openid-configuration. Keycloak issues JWT access tokens, so the default verify_id_token: false works.jwt mode instead. It needs no client secret and no base_url:
aud claim for a custom audience on its own, so add an “Audience” mapper for canonic-mcp to the token. Without audience, any token the realm issued for any resource is accepted. If your Keycloak does not publish OIDC discovery, set jwks_uri to https://keycloak.example.com/realms/canonic/protocol/openid-connect/certs.
Under a tenancy or role policy, the IdP must sign the tenancy and role claims into the token, since canonic reads the principal from the verified claims and from nothing else. A user without a merchant_id claim resolves as a caller without a tenant and fails closed (TENANT_UNRESOLVED) unless their role is tenancy_exempt.
Where to go next
- Connecting your agent for bearer tokens, OAuth modes, headless clients and CI pipelines
- Config schema for every
mcp.auth.oauthfield - Tenancy & access control for how the verified claims become a
Principal