Per-user OAuth for MCP servers
Coulisse can authenticate each end-user independently with third-party MCP servers (Todoist, Atlassian, GitHub, Google, and others) using OAuth 2.0/2.1. When an agent calls a tool on an OAuth-enabled MCP server, Coulisse automatically uses the credentials that the requesting user has authorized.
⚠️ Trust boundary: Coulisse trusts the
user_idpassed in the chat request'ssafety_identifierfield the same way Stripe trusts acustomer_id— it assumes the caller is your authenticated backend, not an end-user directly. If you expose Coulisse's/v1/endpoint directly to untrusted clients without an auth proxy, any client can claim anyuser_idand access another user's connected accounts. Always place an auth proxy (your own backend, a gateway, or Coulisse'sauth.proxyOIDC scope) between Coulisse and untrusted callers before deploying with OAuth-enabled MCP servers.
Just point at the URL
For a spec-compliant MCP server, you write nothing about OAuth at all. A
remote MCP is just a url::
mcp:
todoist:
url: https://ai.todoist.net/mcp
URL-based servers get per-user OAuth discovery + Dynamic Client Registration
automatically. Tokens land in Coulisse's per-user vault, keyed by
(server, user_id) — no Node process, no shared on-disk cache, no
browser-callback port. The transport is inferred from the path (/sse in the
path → SSE, otherwise streamable HTTP); force it with an explicit
transport: http|sse when the path doesn't make it obvious.
If you need to tune the flow, the oauth: block has three uses:
- Disable auth on a public, no-auth HTTP MCP:
oauth: false. - Set scopes while keeping automatic discovery:
oauth: { scopes: [a, b] }(mode defaults todiscover). - Static credentials for a provider without Dynamic Client Registration:
oauth: { mode: static, ... }(see below).
Servers that only honour mcp-remote's client id
A few providers (Todoist today) haven't opened registration and only accept
the grandfathered client id baked into the mcp-remote CLI. For those, declare
the stdio command form yourself and let mcp-remote carry the token:
mcp:
todoist:
command: npx
args: [-y, mcp-remote, https://ai.todoist.net/mcp]
This runs mcp-remote as a stdio child the normal way; Coulisse doesn't
rewrite or inspect it. Use the plain url: form for any server that supports
Dynamic Client Registration.
Two flavours
oauth: blocks come in two modes, picked with the mode: discriminator
(which defaults to discover, so you only write mode: to select static):
mode: discover(default) — MCP-spec OAuth 2.1 with discovery + Dynamic Client Registration. Coulisse reads the provider's authorization-server metadata from<mcp_origin>/.well-known/oauth-authorization-serverand registers itself as a client on first use. No credentials in YAML. This is the right choice for modern MCP servers — Todoist, Atlassian (mcp.atlassian.com), Linear, and so on, and is what a bareurl:uses.mode: static— classic OAuth 2.0 with pre-registered app credentials. You register Coulisse as a client at the provider's developer console and paste the resultingclient_id/client_secrethere. Use this for providers that don't support Dynamic Client Registration.
Both modes drive the same per-user token flow: tokens are stored in the vault
keyed by (server_name, user_id), never shared across users.
How it works
-
Tool call hits
NotConnected: The user makes a chat request, the agent calls a tool on the MCP server, Coulisse looks up(server, user_id)in the vault, finds no token, and returns aNotConnectedToolplaceholder whose tool result contains a per-user, single-use connect URL built from the HMAC key. The LLM reads that result and relays the URL to the user.For agents that haven't pinned an
only:list (the common case — "give the agent every tool the server exposes"), Coulisse can't know the real tool schemas until someone has authorised at least once. Until then it surfaces a single sentinel tool namedconnect_<server>whose description tells the LLM to call it when the user asks to use that server. Calling it returns the same per-user connect URL. Once the user authorises, the sentinel goes away and the real tool list takes its place transparently. -
User clicks the link: lands on
GET /mcp/{server}/connect?token=…on Coulisse. Coulisse validates the HMAC, then fordiscovermode only, lazily runs discovery + Dynamic Client Registration if it hasn't yet (cached inmcp_oauth_clientsafterwards). Discovery is a two-step walk: first<mcp_origin>/.well-known/oauth-protected-resource(RFC 9728) to find which issuer hosts the authorization server (Todoist's MCP lives onai.todoist.net, its auth server lives ontodoist.com), then<issuer>/.well-known/oauth-authorization-server(RFC 8414) for the actual endpoints. Coulisse then 302s to the provider'sauthorization_endpoint. -
User authorizes: signs into their own account at the provider, sees a consent screen, and the provider redirects back to Coulisse's callback.
-
Token stored: Coulisse exchanges the code for tokens and stores them encrypted in
mcp_oauth_tokensunder the user's id. -
Subsequent tool calls succeed: the next chat turn on the same
user_idspawns a real per-user MCP session backed by the stored token.
Every user authorizes independently. Alice's token is never usable by Bob — they have separate vault rows, separate MCP sessions, and separate consent flows.
YAML configuration
Discover mode (recommended for spec-compliant servers)
public_base_url: http://localhost:8421 # see "Public base URL" below
mcp:
todoist:
url: https://ai.todoist.net/mcp
# oauth is implied; add a block only to override scopes:
# oauth: { scopes: [data:read_write] }
auth:
mcp_consumer_secret: "${COULISSE_MCP_SECRET}"
Nothing else to fill in — a bare url: already implies discover-mode OAuth.
Coulisse handles discovery and DCR on first use.
Static mode (for non-DCR providers)
mcp:
jira:
url: https://mcp.atlassian.example.com
oauth:
mode: static
authorization_url: https://auth.atlassian.com/authorize
client_id: "${JIRA_CLIENT_ID}"
client_secret: "${JIRA_CLIENT_SECRET}"
redirect_uri: https://coulisse.example.com/mcp/jira/oauth/callback
scopes:
- read:jira-work
- write:jira-work
token_url: https://auth.atlassian.com/oauth/token
auth:
mcp_consumer_secret: "${COULISSE_MCP_SECRET}"
oauth: block fields
| Field | Mode | Description |
|---|---|---|
mode | both | discover or static |
scopes | both | OAuth scopes to request (optional; discover falls back to scopes_supported) |
authorization_url | static | Provider's OAuth authorize endpoint |
client_id | static | OAuth application client ID |
client_secret | static | OAuth application client secret; ${ENV} expansion supported |
redirect_uri | static | Must match what you registered with the provider |
token_url | static | Provider's token exchange endpoint |
For discover mode, the redirect_uri is computed automatically from
public_base_url as {public_base_url}/mcp/{server}/oauth/callback. The
authorization, token, and registration endpoints all come from discovery.
Public base URL
Coulisse needs to know its own externally reachable URL to build OAuth redirect URIs and the per-user connect links surfaced to LLMs:
public_base_url: https://coulisse.example.com # no trailing slash
If omitted, defaults to http://localhost:{port}, which is right for personal
and local-dev setups. Set it explicitly when Coulisse runs behind a tunnel,
reverse proxy, or on a public hostname — the same value must match whatever the
OAuth provider sees as the redirect URI host.
Secrets (zero config by default)
Coulisse needs two long-lived 32-byte secrets when an OAuth-enabled MCP server is configured:
- vault key — encrypts stored tokens (and any cached DCR
client_secret) at rest with AES-256-GCM - HMAC key — signs the per-user connect links Coulisse mints for the LLM, plus the OAuth
statetoken
You don't have to manage these for local use. On first boot Coulisse
generates both and writes them to .coulisse/secrets.env (mode 0600,
already .gitignored), then reuses the file on every subsequent start.
Back this file up. Losing it invalidates every token in
mcp_oauth_tokens — users have to re-authorize each connected MCP server.
For deployments that source secrets from a vault/k8s/CI, set them as environment variables and Coulisse will use those instead of touching the on-disk file:
| Variable | Purpose |
|---|---|
COULISSE_VAULT_KEY | 32 bytes, base64-encoded. Overrides the on-disk vault key. |
COULISSE_HMAC_KEY | 32 bytes, base64-encoded. Overrides the on-disk HMAC key. |
Both are optional. Resolution order: env vars > .coulisse/secrets.env > generated on the fly.
One additional optional secret gates the admin endpoint only:
| Variable | Purpose |
|---|---|
COULISSE_MCP_SECRET (via auth.mcp_consumer_secret) | Arbitrary string. When set, gates POST /mcp/{server}/connect-link. When unset, that endpoint returns 503 and the per-user GET /connect flow keeps working. |
Endpoints
Coulisse exposes three OAuth-related HTTP routes:
GET /mcp/{server}/connect
The user-facing route. The URL Coulisse mints inside NotConnectedTool looks
like this and is what the LLM hands the user:
{public_base_url}/mcp/{server}/connect?token={hmac_signed_token}
The token is HMAC-signed with COULISSE_HMAC_KEY and embeds the user_id
plus a 10-minute expiry. The handler:
- Validates the HMAC and expiry.
- For
discovermode: ensures the server is registered (lazily runs discovery + DCR on the first hit; reuses the cachedmcp_oauth_clientsrow on subsequent hits). - 302-redirects to the provider's authorization endpoint with a fresh
statetoken carrying the sameuser_id.
POST /mcp/{server}/connect-link
Admin-facing alternative. Bearer-authed with COULISSE_MCP_SECRET. Useful when
your backend wants to email a user a connect link without going through the
LLM's tool result:
POST /mcp/{server}/connect-link?user_id=<user_id>
Authorization: Bearer <mcp_consumer_secret>
Response 200:
{ "url": "https://...provider.../authorize?client_id=...&state=<signed_token>" }
Hand this URL to your end-user. Valid for 10 minutes.
Error codes:
| Code | Reason |
|---|---|
| 401 | Wrong or missing consumer secret |
| 404 | Server name not found in config |
| 422 | user_id query parameter missing, or server exists but has no oauth: block |
| 502 | Discovery or DCR failed (discover mode only — check Coulisse logs) |
GET /mcp/{server}/oauth/callback
The provider's redirect target. Coulisse validates the state HMAC, exchanges the authorization code for tokens, stores them encrypted in SQLite, and shows an HTML success page to the user.
A tampered or expired state returns HTTP 400.
Token + client storage
Two tables under the shared SQLite database, both maintained by the mcp
crate's schema migrator:
mcp_oauth_tokens— encrypted per-user tokens keyed by(server_name, user_id). AES-256-GCM with the nonce prepended. Connecting again overwrites the previous token.mcp_oauth_clients— cached Dynamic Client Registration fordiscovermode servers. One row per server. Theclient_secretis encrypted when present; themetadata_jsondocument is stored plaintext (the provider's authorization-server metadata isn't a secret). Coulisse-wide, not per-user — theclient_ididentifies the Coulisse instance, not the end user.
Per-user session lifecycle
stdio transport: Each (user_id, server_name) gets its own spawned process
on first use, held in an LRU cache (cap: 256 by default, idle timeout: 30
minutes). The access token is passed as the MCP_OAUTH_TOKEN environment
variable. (Most spec-compliant MCP servers use HTTP transport — the stdio path
is for servers you launch via an explicit command:, such as a self-declared
npx mcp-remote <url> shim.)
HTTP transport: A per-user connection is established with
Authorization: Bearer <token> as a default header. Same LRU cache applies.
When a user hasn't connected yet
If an agent calls a tool on an OAuth-enabled MCP server and the calling user has no stored token (or the token is expired), the tool returns a placeholder result containing the connect URL. The LLM reads it and relays it to the user:
Not connected: the user has not authorized access to the 'todoist' MCP server. Show them this link and ask them to open it to link their account — the link is single-use and tied to their identity, do not share it with anyone else:
http://localhost:8421/mcp/todoist/connect?token=…
This is a tool result, not a 500 error. The user clicks the link, authorizes, and the next chat turn just works. No backend intervention required for the common case.