Remote MCP with OAuth
Connecting an AI agent over MCP covers the local paths: stdio for an agent on the same machine, plain HTTP on loopback. To let hosted agents — claude.ai custom connectors, ChatGPT connectors, a Claude Code session on another machine — reach your MCP server over the internet, every request needs a verified identity.
That is what the server.mcp.http.auth block does. Its presence switches the MCP HTTP endpoint into OAuth 2.1 resource-server mode (the MCP authorization spec): Kozou advertises your identity provider via protected-resource metadata (RFC 9728), verifies each request’s bearer token, gates the tool surface on the token’s scopes, and runs execution — the call tool — under SET LOCAL ROLE <role-from-token>, so per-caller identity is enforced by your PostgreSQL RLS policies. (The describe tools serve introspected schema context; scopes decide whether a caller sees them at all.)
This guide covers the mode itself and how to deploy it safely. For the identity-provider side, there are worked recipes for Keycloak and Auth0.
What Kozou does — and refuses to do
Section titled “What Kozou does — and refuses to do”Kozou is a resource server only, the same delegation posture as the REST surface:
- It verifies bearer tokens against your identity provider’s published keys, checks issuer, audience, and scopes, and maps the role claim to a PostgreSQL role.
- It advertises your authorization server so MCP clients can discover where to sign in.
- It never issues tokens, hosts no login or consent UI, and registers no clients. Those belong to your IdP — the system built to guard identity.
Before you start
Section titled “Before you start”- An OAuth 2.1 / OIDC identity provider that hosted clients can reach, with authorization-server metadata and a JWKS endpoint. Any provider works if it can satisfy the next two points.
- The provider must put the right audience in access tokens. Kozou requires the token’s
audto match the MCP server’s resource URI. Providers that implement RFC 8707 resource indicators (Auth0) do this natively; others (Keycloak) need an audience mapper — the recipes cover both. - A scalar role claim naming the PostgreSQL role to assume, exactly as in enterprise SSO.
- PostgreSQL roles and RLS policies for the roles you will allow, with the login role in
database.urlGRANTed membership in each. - A public https URL for the endpoint — a reverse proxy or tunnel in front of Kozou is the normal shape.
Turn it on
Section titled “Turn it on”Add the auth block under server.mcp.http in kozou.config.yaml:
server: mcp: http: port: 3334 host: 127.0.0.1 # keep the bind local; the proxy fronts it auth: resource: https://mcp.example.com/mcp authorizationServers: - https://idp.example.com/realms/myrealm jwt: jwksUri: https://idp.example.com/realms/myrealm/protocol/openid-connect/certs algorithms: [RS256] roleClaim: role allowedRoles: [app_viewer, app_editor] extraScopesSupported: [offline_access] execution: enabled: true # optional: expose the `call` toolThen run the server with kozou mcp --http. The block applies to the HTTP transport only — a stdio run ignores it (and says so on startup), because stdio’s security boundary is process access, not the network.
The auth block, key by key
Section titled “The auth block, key by key”resource— the canonical resource URI of this MCP server (RFC 8707). Always explicit, never derived from theHostheader (header-derived identity is exactly what the DNS-rebinding guard exists to distrust). Behind a proxy or tunnel this is the public URL. It is also the default expected token audience.authorizationServers— the issuer URLs advertised in the protected-resource metadata; clients discover your IdP from this list. Unless you setjwt.issueryourself, these also become the expectedissof every accepted token —issis always verified.jwt— the verification config: exactly one ofjwksUri/publicKey/secret, plus optionalalgorithmsandissuer. When omitted it is inherited from the top-levelauthblock — exceptaudience, which is never inherited: the REST audience is a client id, while the MCP audience is the resource URI. If your provider cannot put the resource URI inaud, setjwt.audiencehere to what it does issue.roleClaim/allowedRoles— inherited from the top-levelauthblock when omitted.allowedRolesis the allowlist of roles a token may select; see Strict by design for when it is required.scopes— renames the expected scope names (defaults:mcp:describe,mcp:execute,mcp:admin) for IdPs that force a prefix onto scope names. Matching against the token is exact.extraScopesSupported— extra names appended to the advertised scope list. Clients treat that list as “what to request from the authorization server” — some echo it verbatim into their dynamic client registration — so list everything a client should ask for. The common case isoffline_accesswhen your IdP requires it for refresh tokens (Keycloak does). These are advertised only; Kozou itself never requires them.adminRefresh— defaultfalse: in auth modePOST /admin/refreshis disabled entirely (404, indistinguishable from an unknown path). Opting in keeps it reachable, gated on a valid token carrying the admin scope. The admin scope is deliberately not advertised — clients that echo the advertised list would otherwise request an admin grant they never need.allowInsecureHttp— defaultfalse: a plaintexthttp:resource or authorization-server URL on a non-loopback host is a startup error, because these URLs are handed to third-party clients and carry bearer tokens. Loopback (localhost,127.0.0.0/8,::1) is always fine for local development. Opting in logs a startup warning; it exists for isolated test networks, never production.
Strict by design
Section titled “Strict by design”This surface is deliberately stricter than the REST one. Three REST conveniences do not exist here:
- No anonymous access. A request without a token is always
401— there is noanonRole. The 401 is not a dead end: it carries aWWW-Authenticatechallenge pointing at the protected-resource metadata, and that challenge is how real clients discover your IdP in the first place. Letting anonymous requests through would blur the discovery surface, and describe metadata is worth protecting anyway. - No default role. A verified token whose role claim is missing is rejected — there is no
defaultRole. A default here would silently grant a role to any authenticated principal your IdP admin never assigned one — for a federated directory (Google Workspace behind Keycloak or Auth0), that is every first-time user. Role assignment stays an explicit IdP action; the recipes show where it lives. allowedRolesbecomes mandatory with execution. Whenserver.mcp.execution.enabledis on together with the auth block, a non-empty effectiveallowedRoles(set here or inherited) is required at startup. The token’s role claim selects the execution role, so the assumable roles must be an explicit allowlist — a broad connection role must not let a mis-mapped IdP claim select whatever role the database happens to permit.
Scopes gate the tool surface itself: the seven describe tools require the describe scope, the call tool requires execute. Tools whose scope the token does not carry are not advertised to that client, and calling one anyway returns a 403 insufficient_scope challenge naming the missing scope.
Execution: per-caller identity
Section titled “Execution: per-caller identity”Without the auth block, enabling the call tool requires a single fixed execution.role — every caller shares it, which is unsuitable for multi-tenant authorization.
With the auth block, call runs as each verified token’s role, and a configured execution.role / execution.claims are ignored. The token’s claims are published to your RLS policies (via auth.claimsGuc, default request.jwt.claims) exactly as on the REST surface — one set of policies applies to every entry point.
Behind a proxy or tunnel
Section titled “Behind a proxy or tunnel”The normal deployment keeps Kozou bound to loopback with a reverse proxy or tunnel terminating TLS in front:
- Set
resourceto the public URL. Its hostname is added to the DNS-rebinding guard automatically, so tunneled requests pass theHostcheck without extra configuration. - The protected-resource metadata is served on both well-known forms — the root form (
/.well-known/oauth-protected-resource) and the path-insertion form (…/oauth-protected-resource/mcp) — because real clients fetch either depending on context. Both come from the same document; nothing to configure. - Keep https end-to-end to the proxy. The startup transport check (
allowInsecureHttp) refuses accidental plaintext exposure. - If you use privilege-aware introspection (
introspection.respectPrivileges) together with this mode,allowedRolesmust name exactly one role: the describe annotations describe one role’s privileges, which is only truthful when only one role is assumable.
What each client does
Section titled “What each client does”The three major hosted clients all speak the same discovery protocol but differ in registration and consent. Knowing the differences up front saves debugging time:
- Claude Code registers via dynamic client registration and echoes the advertised scope list into its registration verbatim — which is why
extraScopesSupported: [offline_access]matters on IdPs that gate refresh tokens behind that scope. The registration is cached per server URL;claude mcp logoutdiscards it and the next login registers a fresh client. - claude.ai (custom connectors) attempts dynamic registration and authorizes almost immediately afterwards. On IdPs where each new client needs a separate approval step (Auth0), that race fails repeatedly — the stable path is a pre-registered confidential client: create it in the IdP, approve it once, and paste its client ID and secret into the connector’s settings. claude.ai also interposes a per-tool consent dialog (“Allow / Deny”) on each tool it uses — expect your users to see one per tool, not one per connector.
- ChatGPT (connectors) detects the absence of client-ID metadata documents and falls back to dynamic registration — registering a new client on every connect attempt, which on approval-per-client IdPs means automating the approval or pre-registering here too. It requests every advertised scope up front. One UX trap to warn your users about: “publishing” the connector and “connecting” it are separate steps — right after publishing, the app can show no available actions (the unauthenticated probe got a 401, correctly). The OAuth sign-in never runs by itself at creation or publish time: depending on the ChatGPT surface it triggers on first use in a chat, or must be started explicitly from the app’s page (a Connect button on its directory entry). Once a user completes it, the actions appear. ChatGPT also re-initializes before most requests and probes
resources/list; Kozou handles both.
Operating it
Section titled “Operating it”- Prefer pre-registered confidential clients for anything you distribute. Dynamic registration is great for a developer connecting their own agent, but hosted distribution runs into per-client approvals, registration races, and IdP client quotas. One pre-registered client per tenant sidesteps all three.
- Never delete a dynamically-registered client that may still be in use. Clients cache their
client_id; after a deletion the IdP’sinvalid_clienterror surfaces to the end user as an opaque failure. Garbage-collect only on explicit revocation. - Treat role assignment as part of onboarding. A new user in a federated directory arrives with no role claim and is rejected by design. The flow is: user exists in the directory → IdP admin assigns the role (a user attribute, app metadata, or app-role mapping — see the recipes) → the user’s next token carries it.
Where to next
Section titled “Where to next”- Keycloak as the authorization server — the self-hosted recipe, including the mapper placement rule that everything hinges on.
- Auth0 as the authorization server — the SaaS recipe, including the per-client approval wall and the claude.ai client setup.
- Connect an AI agent over MCP — the local paths and what the tools expose.
- Authentication and authorization — what Kozou enforces and what it delegates.
kozou.config.yaml— the configuration file this block lives in.