Skip to content

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.

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.
  • 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 aud to 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.url GRANTed membership in each.
  • A public https URL for the endpoint — a reverse proxy or tunnel in front of Kozou is the normal shape.

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` tool

Then 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.

  • resource — the canonical resource URI of this MCP server (RFC 8707). Always explicit, never derived from the Host header (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 set jwt.issuer yourself, these also become the expected iss of every accepted token — iss is always verified.
  • jwt — the verification config: exactly one of jwksUri / publicKey / secret, plus optional algorithms and issuer. When omitted it is inherited from the top-level auth block — except audience, 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 in aud, set jwt.audience here to what it does issue.
  • roleClaim / allowedRoles — inherited from the top-level auth block when omitted. allowedRoles is 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 is offline_access when your IdP requires it for refresh tokens (Keycloak does). These are advertised only; Kozou itself never requires them.
  • adminRefresh — default false: in auth mode POST /admin/refresh is 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 — default false: a plaintext http: 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.

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 no anonRole. The 401 is not a dead end: it carries a WWW-Authenticate challenge 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.
  • allowedRoles becomes mandatory with execution. When server.mcp.execution.enabled is on together with the auth block, a non-empty effective allowedRoles (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.

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.

The normal deployment keeps Kozou bound to loopback with a reverse proxy or tunnel terminating TLS in front:

  • Set resource to the public URL. Its hostname is added to the DNS-rebinding guard automatically, so tunneled requests pass the Host check 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, allowedRoles must name exactly one role: the describe annotations describe one role’s privileges, which is only truthful when only one role is assumable.

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 logout discards 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.
  • 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’s invalid_client error 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.