Skip to main content
The marketplace guide demonstrates tenant scoping and role-based access control with five static bearer tokens. This guide serves the same project behind a real OIDC identity provider (Keycloak) using 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/
The setup never modifies 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:
Wait for the Keycloak log line confirming that realm canonic was imported, and for Uvicorn’s Uvicorn running on http://0.0.0.0:7474 line from the canonic container.

Test users

The realm canonic 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 the mcp.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 at http://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.
  1. Log in as byte-gadgets-viewer / viewer and query the marketplace metrics grouped by customer_email. The query is rejected with an unreachable error, because merchant_viewer denies customer_email and customer_phone as dimensions, and a denied dimension looks exactly like a nonexistent one. run_sql is refused as well (run_sql: false).
  2. Disconnect and log in as byte-gadgets-admin / admin. The same query now works and customer_email comes back masked (ab***). run_sql is accepted by the role but still refused with TENANT_FORBIDDEN, because marketplace_db has rls_enforced: false (see run_sql’s two gates).
  3. Log in as platform-ops / platform. Data is visible across both merchants, because the role is tenancy_exempt: true and the token carries no merchant_id.
These are the same results as in the marketplace guide, now driven by the IdP login.

Sanity check without a browser

The static tokens from the compose file still work, which allows a quick check with curl:

Stop

Adapting this to a real Keycloak

The realm export is a local-development artifact. Passwords, the client secret and sslRequired: 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.
If the client that talks to canonic already holds a Keycloak token and canonic should only verify it, use jwt mode instead. It needs no client secret and no base_url:
Keycloak does not add an 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