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

:::note

This policy is **code-config only** today. The Portal UI surfaces Origin MCPs
and Virtual MCPs but does not yet expose upstream OAuth configuration. Configure
it in `config/policies.json` and route attachments in `config/routes.oas.json`.
See the [code-config overview](../code-config/overview.mdx) for the full 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.                                                                                                                                               |

Auto is the right default. It requires nothing from the upstream provider beyond
standard MCP authorization spec support, and it has no client secrets to rotate.
Use manual when the upstream provider blocks both CIMD and DCR, or when your
organization requires a pre-vetted OAuth app registration.

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 DCR; `manual` uses a pre-registered 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. Switch to manual registration with a pre-registered OAuth app.
- **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)
