# Connect a gateway to an upstream OAuth provider

When an upstream MCP server requires OAuth — either per user or as a shared
service account — attach the `mcp-token-exchange-inbound` policy to the route.
The policy resolves the user's upstream credential and applies it to the
upstream request, returns a connect-required error when the user hasn't yet
authorized the upstream, and refreshes the credential transparently.

For the conceptual model behind the policy — the two auth modes, client
registration, the consent flow, and connect-required states — see
[Per-user OAuth to upstream MCP servers](../auth/upstream-oauth.mdx).

## Add the token-exchange policy

1. Declare one `mcp-token-exchange-inbound` policy per upstream MCP server in
   `config/policies.json`:

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

2. Attach the policy to the route in `config/routes.oas.json`, **after** the
   inbound MCP OAuth policy:

   ```jsonc title="config/routes.oas.json"
   "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"}

MCP Gateway features require `compatibilityDate >= 2026-03-01` in `zuplo.jsonc`.
See [Compatibility dates](../code-config/compatibility-dates.mdx).

:::

## Pick an auth mode

Set `authMode` based on who owns the upstream credential:

- **`"user-oauth"`** — each user has their own per-upstream OAuth connection.
  This is the default and the right choice for Linear, Notion, Stripe, GitHub,
  and most SaaS MCP servers.
- **`"shared-oauth"`** — one gateway-wide OAuth grant used by every user. An
  administrator completes a one-time connection; subsequent user requests reuse
  the shared credential. Pick shared mode when the upstream uses a service
  account that represents the organization rather than individual users.

## Pick a client registration mode

Set `clientRegistration` based on how the gateway should identify itself to the
upstream OAuth provider:

- **`{ "mode": "auto" }`** (default) — the gateway publishes a per-upstream
  OAuth Client ID Metadata Document 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. No upstream client credentials live in
  source control.
- **`{ "mode": "manual", "clientId": "...", "clientSecret": "...", "tokenEndpointAuthMethod": "client_secret_basic" }`**
  — pre-registered OAuth app. The gateway uses the `clientId` directly and
  authenticates to the upstream token endpoint with the configured method. Pick
  manual mode when your organization manages OAuth client lifecycle centrally,
  when the upstream requires an approved client, or when you need to share one
  OAuth client across multiple routes.

Use `$env(...)` for `clientSecret` so the secret stays out of source control.

## Set scopes when the upstream needs them

When the upstream requires specific scopes that aren't discoverable from MCP
metadata, set `scopes` explicitly:

```jsonc
{
  "options": {
    "scopes": ["mcp"],
  },
}
```

When `scopes` is omitted or empty, the gateway falls back through the upstream's
most recent `WWW-Authenticate` challenge, then the `scopes_supported` array in
Protected Resource Metadata, then no `scope` parameter at all. Microsoft 365,
Slack, PostHog, Stripe, Grafana Cloud, and several other providers fall into the
bucket where explicit `scopes` are required.

## Override the Protected Resource Metadata URL

By default, the gateway derives the upstream PRM 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 with
`protectedResourceMetadataUrl`. Linear, for example, serves PRM at the origin's
root, not under `/mcp`:

```jsonc
{
  "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.

## Test the connect flow

After deploying (or restarting `zuplo dev`):

1. Connect a test client (the [MCP Inspector](../test-clients.mdx#mcp-inspector)
   is the fastest option) to the route as a fresh user.
2. The first MCP request returns a JSON-RPC connect-required error with an
   `authUrl`. Modern MCP clients open the URL automatically; older clients
   surface it for the user to copy.
3. Complete the upstream provider's OAuth flow in the browser. The gateway
   stores the resulting tokens encrypted, keyed by the user's subject ID.
4. The next MCP request succeeds. Subsequent requests reuse the stored
   credential transparently.

For deeper debugging — including a manual `curl` walkthrough of the OAuth flow —
see [Manual OAuth testing](../auth/manual-oauth-testing.mdx).

## Worked examples

### Linear (auto registration, PRM override)

```jsonc title="config/policies.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, per-user OAuth.",
      "protectedResourceMetadataUrl": "https://mcp.linear.app/.well-known/oauth-protected-resource",
      "authMode": "user-oauth",
      "scopes": [],
      "clientRegistration": { "mode": "auto" },
    },
  },
}
```

The corresponding route:

```jsonc title="config/routes.oas.json"
"/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)

```jsonc title="config/policies.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, per-user 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)

```jsonc title="config/policies.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" },
    },
  },
}
```

## Non-OAuth upstreams

`mcp-token-exchange-inbound` only handles OAuth. For other credential shapes,
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 upstream credential policy is needed —
  `McpProxyHandler` proxies through directly.

## Related

- [Per-user OAuth to upstream MCP servers](../auth/upstream-oauth.mdx) — the
  conceptual model behind the policy.
- [`McpProxyHandler` reference](../code-config/mcp-proxy-handler.mdx) — the
  route handler the token-exchange policy attaches credentials for.
- [Add multiple upstream MCP servers](../code-config/multi-upstream.mdx) — apply
  the same pattern across several upstreams in one project.
- [Manual OAuth testing](../auth/manual-oauth-testing.mdx) — drive the upstream
  OAuth surface with `curl` for low-level verification.
