# Per-user OAuth to upstream MCP servers

The `mcp-token-exchange-inbound` policy resolves a gateway-managed upstream
credential and applies it to the request before the proxy forwards it. It's the
**upstream** side of the [two-layer authentication model](./overview.mdx) —
every request that reaches an OAuth-protected upstream MCP server goes through
it.

This page covers what the policy does, the two auth modes it supports, how
client registration works, the user-facing browser consent flow, and the moving
parts around token refresh and reconsent. The full options schema lives on the
policy reference page.

Configure the policy in `config/policies.json` and attach it to each MCP route
in `config/routes.oas.json`. See the
[code-config overview](../code-config/overview.mdx) for the full project setup.

## What it does

On every MCP request to a route that uses the policy:

1. Read the gateway-issued bearer (already validated by the downstream OAuth
   policy in front of this one) to identify the user.
2. Look up the stored **upstream connection** for that user and upstream.
3. If a usable upstream access token exists, inject it as
   `Authorization: Bearer <upstream-token>` and let the proxy forward.
4. If the upstream connection is missing or revoked, return a JSON-RPC
   **connect-required** error pointing at the URL the user must open to complete
   upstream OAuth.
5. Install a response hook that watches for an upstream `401`. On hit, refresh
   the upstream credential (using any new `scope=` advertised in the upstream
   `WWW-Authenticate` header) and retry the upstream fetch once.
6. Strip the inbound `Authorization`, `Cookie`, and `Cookie2` headers from the
   request so they never leak upstream.

The downstream OAuth policy and this policy are paired on the same route:

```jsonc
{
  "policies": {
    "inbound": ["auth0-managed-oauth", "mcp-token-exchange-linear"],
  },
}
```

Only one MCP token-exchange policy is allowed per route. The route's upstream
URL comes from `McpProxyHandler`'s `rewritePattern` option, not from the policy.

:::caution{title="Compatibility date 2026-03-01"}

This policy requires `compatibilityDate >= 2026-03-01` in `zuplo.jsonc`. The
upstream 401 retry hook depends on chained response-hook semantics that landed
on that date. Older projects must bump the compatibility date before adding this
policy. See [compatibility dates](../code-config/compatibility-dates.mdx) for
details.

:::

## When to use this policy

Use `mcp-token-exchange-inbound` when the upstream MCP server requires OAuth —
either per user or as a shared service account. **Both modes are OAuth.** The
policy doesn't handle static API keys or arbitrary header injection.

For non-OAuth upstreams, omit this policy and compose ordinary Zuplo policies
alongside `McpProxyHandler`:

- **API key in a custom header:** use
  [`set-upstream-api-key-inbound`](../../policies/set-upstream-api-key-inbound.mdx).
- **Static request headers:** use
  [`SetHeadersInboundPolicy`](../../policies/set-headers-inbound.mdx).
- **Anonymous upstream:** no policy is needed — `McpProxyHandler` proxies
  through directly.

The corp dogfood gateway uses `SetUpstreamApiKeyInboundPolicy` for Firecrawl
alongside other upstreams that use OAuth, all in the same project.

## Auth modes

`authMode` is the central knob — it decides who owns the upstream credential.

| `authMode`       | Owner                                                  | Use case                                                                                                                                               |
| ---------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `"user-oauth"`   | Each user has their own per-upstream OAuth connection. | The default. Linear, Notion, Stripe, GitHub, most SaaS MCP servers.                                                                                    |
| `"shared-oauth"` | One gateway-wide OAuth grant used by all users.        | A single service account or admin-owned connection. An administrator completes a one-time setup; subsequent user requests reuse the shared credential. |

### user-oauth

Per-user is the standard mode and what most upstreams use. The first time each
user hits a route, the policy returns a connect-required error; the user opens
the URL in a browser; they complete the upstream provider's OAuth flow; the
gateway stores the resulting tokens encrypted, keyed by the user's subject ID.
Subsequent requests from that user are transparent.

### shared-oauth

Shared mode uses a single gateway-wide OAuth grant. There's no per-user connect
flow — instead, an administrator completes a one-time connection, and every
authenticated user reuses that credential when calling the upstream. The gateway
returns an `admin_connect_required` connect-required error if no shared
connection exists.

Shared mode is appropriate when:

- The upstream uses a service account that represents the organization, not
  individual users.
- Auditing happens at the gateway level (per user) rather than at the upstream
  (where every call looks like the same service account).

## Client registration

The `clientRegistration` option determines how the gateway identifies itself to
the upstream OAuth provider.

| Mode                                                                                                               | What happens                                                                                                                                                                                                                                                                                   |
| ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `{ "mode": "auto" }` (default)                                                                                     | The gateway publishes a per-upstream **OIDC Client ID Metadata Document** at `/.well-known/oauth-client/{connection}?authProfileId=...` and tells the upstream that URL is the client ID. If the upstream doesn't accept CIMD, the gateway falls back to RFC 7591 Dynamic Client Registration. |
| `{ "mode": "manual", "clientId": "...", "clientSecret": "...", "tokenEndpointAuthMethod": "client_secret_basic" }` | Pre-registered OAuth app. The gateway uses your `clientId` directly and authenticates to the upstream token endpoint with the configured method.                                                                                                                                               |

Both modes are first-class. Use `auto` for upstreams that support CIMD or DCR
out of the box — it requires nothing from the upstream provider beyond standard
MCP authorization spec support and has no client secrets to rotate. Use `manual`
when you want to pin a pre-registered OAuth app: your organization manages OAuth
client lifecycle centrally, the upstream requires an approved client, or you
need to share one OAuth client across multiple routes.

Auto-mode CIMD documents are accessible to the upstream provider over HTTPS —
the upstream fetches them as part of its OAuth registration flow. The CIMD URL
includes the `authProfileId` query parameter so the gateway can scope client
identity per `(upstream, authMode)` pair.

## Scope selection

`scopes` is an optional array. When set, the gateway uses exactly those values
on every upstream authorization request, joined by `scopeDelimiter` (default
single space).

When `scopes` is omitted or empty, the gateway falls back through the following
sources in order:

1. The `scope=` value from the upstream's most recent `WWW-Authenticate`
   challenge.
2. The `scopes_supported` array in the upstream's Protected Resource Metadata.
3. No `scope` parameter at all.

Explicit `scopes` always win. Set them whenever the upstream provider requires
specific values that aren't discoverable from MCP metadata — Microsoft 365,
Slack, PostHog, and several other providers fall into this bucket. The corp
dogfood configures `["grafana:read", "grafana:write"]` for Grafana Cloud and
`["mcp"]` for Stripe, for example.

## Per-user OAuth flow

The browser flow is what users actually see. It runs the first time a user hits
an OAuth-protected upstream they haven't connected, and again whenever the
upstream revokes the gateway's client.

```mermaid
sequenceDiagram
    autonumber
    participant C as MCP Client
    participant G as Zuplo Gateway
    participant U as User browser
    participant P as Upstream OAuth
    participant M as Upstream MCP server

    C->>G: POST /mcp/linear-v1 (Bearer <gateway-token>)
    G->>G: Resolve stored upstream connection for user
    Note over G: No connection (or expired / revoked)
    G->>P: Discover PRM and AS metadata, register client (CIMD/DCR)
    P-->>G: Authorize URL with PKCE
    G-->>C: JSON-RPC UrlElicitationRequiredError with authUrl
    C->>U: Open authUrl in browser
    U->>G: GET /auth/connections/linear/connect?browserTicket=...
    G-->>U: 302 to upstream authorize endpoint
    U->>P: Authenticate and consent
    P-->>U: Redirect to /auth/connections/linear/callback?code=...
    U->>G: GET callback
    G->>P: Exchange code for tokens
    P-->>G: { access_token, refresh_token, ... }
    G->>G: Encrypt and store per-user upstream connection
    G-->>U: Render "connection complete"
    C->>G: POST /mcp/linear-v1 (retry)
    G->>M: POST upstream MCP with Authorization: Bearer <upstream-token>
    M-->>G: JSON-RPC response
    G-->>C: JSON-RPC response
```

Modern MCP clients implement the URL-elicitation extension and open the URL
automatically. Older clients surface the URL as part of the JSON-RPC error
message — the user copies it into a browser.

## Connect-required states

The connect-required error carries a `state` field that distinguishes the three
reasons the user might need to act.

| State                    | Meaning                                                                                                                  | Typical UI message                                                             |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
| `authenticating`         | First-time connection. User hasn't authorized the upstream yet.                                                          | "Connect to `{provider}` to continue."                                         |
| `reconsent_required`     | Existing connection but the upstream revoked the client or invalidated the refresh token. The user needs to reauthorize. | "`{provider}` authorization must be renewed."                                  |
| `admin_connect_required` | `authMode: shared-oauth` and no shared connection exists yet. Only an administrator can complete the flow.               | "An administrator must connect `{provider}` before this service is available." |

The full JSON-RPC error payload looks like:

```jsonc
{
  "jsonrpc": "2.0",
  "id": "1",
  "error": {
    "code": -32042,
    "message": "Connect Linear to continue.",
    "data": {
      "state": "authenticating",
      "upstreamServerId": "linear",
      "operationId": "linear-mcp-server",
      "authUrl": "https://gateway.example.com/auth/connections/linear/connect?browserTicket=eyJ...&operationId=linear-mcp-server",
      "nextAction": "redirect",
      "authProfileId": "linear:user-oauth",
    },
  },
}
```

The `-32042` error code is MCP's `URLElicitationRequiredError`. Clients that
support URL elicitation open `authUrl` directly; others render the message and
let the user open the URL manually.

## Multi-upstream consent

A Virtual MCP routes to exactly one upstream MCP server, so the consent page
typically shows one upstream to connect. The page renders the per-upstream
**Connect** button alongside the **Authorize** action; the **Authorize** action
is enabled once every required upstream connection is complete. The
multi-upstream UI pattern is in place to keep future flows that bind multiple
consent steps (for example, multiple shared service accounts on one project)
consistent.

The page is server-rendered HTML hosted on the gateway. There's no client SDK to
add to your project — the consent page is part of the gateway's internal routes
and renders automatically whenever a user lands at `/oauth/setup` mid-flow.

## Token refresh and the 401 retry hook

The gateway delegates upstream token refresh to the
[`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk)
OAuth client provider. Per-request, the policy resolves the stored connection
and hands the SDK's provider to the proxy; the SDK refreshes the access token
from the stored refresh token transparently.

When the upstream returns a 401 mid-request — for example, because the
upstream's session-bound token expired between the gateway's last refresh check
and the upstream's clock — the policy's response hook:

1. Reads any `scope=` value from the upstream `WWW-Authenticate` header, in case
   the upstream is requesting elevated scopes (MCP's incremental-scope-consent
   path).
2. Force-refreshes the upstream credential.
3. Retries the upstream fetch exactly once.
4. If the refresh itself fails or produces another connect-required, the gateway
   returns the JSON-RPC connect-required to the client.

The retry hook is what requires `compatibilityDate >= 2026-03-01`. Without that
date, later response hooks can overwrite the retry response.

## Per-upstream metadata URL

By default, the gateway derives the upstream Protected Resource Metadata URL
from the route's `rewritePattern`:

```text
rewritePattern:                https://mcp.linear.app/mcp
default PRM URL:               https://mcp.linear.app/.well-known/oauth-protected-resource/mcp
```

When the upstream serves PRM at a non-default path, override it explicitly with
`protectedResourceMetadataUrl`. Linear, for example, serves PRM at the origin's
root, not under `/mcp`:

```json
{
  "options": {
    "displayName": "Linear",
    "protectedResourceMetadataUrl": "https://mcp.linear.app/.well-known/oauth-protected-resource",
    "authMode": "user-oauth",
    "clientRegistration": { "mode": "auto" }
  }
}
```

When in doubt, look at what the upstream's MCP endpoint returns in its
`WWW-Authenticate` header on an unauthenticated request — the
`resource_metadata=` parameter on that header is the canonical URL.

## Worked examples

These are pared-down versions of three policies from the corp dogfood gateway.
Each pairs with an `McpProxyHandler` route whose `rewritePattern` is the
upstream MCP URL.

### Linear (auto registration, PRM override)

```json
{
  "name": "mcp-token-exchange-linear",
  "policyType": "mcp-token-exchange-inbound",
  "handler": {
    "module": "$import(@zuplo/runtime/mcp-gateway)",
    "export": "McpTokenExchangeInboundPolicy",
    "options": {
      "displayName": "Linear",
      "summary": "Linear MCP upstream used to dogfood user-owned OAuth.",
      "protectedResourceMetadataUrl": "https://mcp.linear.app/.well-known/oauth-protected-resource",
      "authMode": "user-oauth",
      "scopes": [],
      "clientRegistration": { "mode": "auto" }
    }
  }
}
```

The corresponding route:

```jsonc
"/mcp/linear-v1": {
  "get,post": {
    "operationId": "linear-mcp-server",
    "x-zuplo-route": {
      "corsPolicy": "none",
      "handler": {
        "module": "$import(@zuplo/runtime/mcp-gateway)",
        "export": "McpProxyHandler",
        "options": { "rewritePattern": "https://mcp.linear.app/mcp" }
      },
      "policies": {
        "inbound": ["auth0-managed-oauth", "mcp-token-exchange-linear"]
      }
    }
  }
}
```

### Stripe (explicit scope)

```json
{
  "name": "mcp-token-exchange-stripe",
  "policyType": "mcp-token-exchange-inbound",
  "handler": {
    "module": "$import(@zuplo/runtime/mcp-gateway)",
    "export": "McpTokenExchangeInboundPolicy",
    "options": {
      "displayName": "Stripe",
      "summary": "Stripe MCP upstream used to dogfood user-owned OAuth.",
      "authMode": "user-oauth",
      "scopes": ["mcp"],
      "clientRegistration": { "mode": "auto" }
    }
  }
}
```

Stripe requires the bare `mcp` scope explicitly. The default PRM URL (derived
from the route's `rewritePattern` of `https://mcp.stripe.com/mcp`) is correct,
so no override is needed.

### Notion (PRM override at `/mcp` path)

```json
{
  "name": "mcp-token-exchange-notion",
  "policyType": "mcp-token-exchange-inbound",
  "handler": {
    "module": "$import(@zuplo/runtime/mcp-gateway)",
    "export": "McpTokenExchangeInboundPolicy",
    "options": {
      "displayName": "Notion",
      "protectedResourceMetadataUrl": "https://mcp.notion.com/.well-known/oauth-protected-resource/mcp",
      "authMode": "user-oauth",
      "scopes": [],
      "clientRegistration": { "mode": "auto" }
    }
  }
}
```

## Full options reference

The complete schema lives on the policy reference page. The fields you'll touch
most often:

| Option                         | Required | Default                                      | Notes                                                                                                       |
| ------------------------------ | -------- | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `id`                           | no       | inferred from `mcp-token-exchange-{id}` name | Stable id for the upstream. Used as the per-user OAuth storage key. Changing it strands stored connections. |
| `displayName`                  | yes      | —                                            | Display name shown in connect-required errors, the consent page, and analytics.                             |
| `summary`                      | no       | —                                            | Human-readable summary on the consent page.                                                                 |
| `authMode`                     | yes      | —                                            | `"user-oauth"` or `"shared-oauth"`.                                                                         |
| `protectedResourceMetadataUrl` | no       | derived from `rewritePattern`                | Override when the upstream serves PRM at a non-default path.                                                |
| `scopes`                       | no       | `[]`                                         | OAuth scopes requested from the upstream. Empty means "use discovery fallback".                             |
| `scopeDelimiter`               | no       | `" "`                                        | Delimiter joining scopes in the authorization request.                                                      |
| `clientRegistration`           | no       | `{ "mode": "auto" }`                         | `auto` uses CIMD then falls back to DCR; `manual` uses a pre-registered OAuth client.                       |
| `clientId`                     | no       | —                                            | OAuth client ID for manual registration.                                                                    |
| `clientSecret`                 | no       | —                                            | OAuth client secret for manual registration. Use `$env(...)`.                                               |
| `tokenEndpointAuthMethod`      | no       | `client_secret_basic` (when manual)          | Manual-mode token endpoint authentication method.                                                           |

## Common issues

- **`compatibilityDate < 2026-03-01`.** The retry hook fails to install
  correctly. Bump the compatibility date in `zuplo.jsonc`.
- **Connect-required loop.** The user completes the upstream flow but the next
  MCP request returns a fresh connect-required error. Usually means the upstream
  provider isn't returning a refresh token, so the gateway treats every request
  as a fresh connect. Check the upstream provider's app configuration for
  refresh-token grant type support.
- **`upstream_client_registration_required` error.** The upstream blocked both
  CIMD and DCR. Use `clientRegistration: { mode: "manual" }` with a
  pre-registered OAuth app instead.
- **Wrong PRM URL.** The default PRM URL doesn't match the upstream's actual
  metadata endpoint. Set `protectedResourceMetadataUrl` explicitly.
- **Scope mismatch.** The upstream rejects the gateway's authorization request
  with `invalid_scope`. Configure `scopes` explicitly with the values the
  upstream expects.

## Related

- [Authentication overview](./overview.mdx)
- `mcp-token-exchange-inbound` policy reference
- [`McpProxyHandler` reference](../code-config/mcp-proxy-handler.mdx)
- [Multi-upstream pattern](../code-config/multi-upstream.mdx)
- [Compatibility dates](../code-config/compatibility-dates.mdx)
- [Manual OAuth testing](./manual-oauth-testing.mdx)
