# Add multiple upstream MCP servers

A single Zuplo deployment can front any number of upstream MCP servers. One
OAuth policy authenticates inbound MCP clients across every route; one
[`mcp-token-exchange-inbound`](../../policies/mcp-token-exchange-inbound.mdx)
policy lives per upstream; one route per upstream wires them together.

This page walks through a worked example with two upstreams — Linear and Stripe
— and explains the per-user storage model the multi-upstream pattern depends on.

## The pattern

Three rules form the pattern:

1. **One MCP OAuth policy, project-wide.** The gateway allows exactly one MCP
   OAuth policy per project, regardless of variant (`mcp-auth0-oauth-inbound` or
   `mcp-oauth-inbound`). Every MCP route attaches the same policy.
2. **One `mcp-token-exchange-*` policy per upstream.** Each upstream MCP server
   gets its own policy with its own `displayName`, `authMode`, `scopes`, and
   optional `protectedResourceMetadataUrl`. The policy's `id` (or the `id`
   inferred from its name) becomes the stable per-upstream key for storage and
   analytics.
3. **One `/mcp/<slug>` route per upstream.** Each route uses
   [`McpProxyHandler`](./mcp-proxy-handler.mdx) with the upstream URL as
   `rewritePattern`, and lists the shared OAuth policy plus the matching token
   exchange policy in its inbound chain.

A typical path convention is `/mcp/<provider>-v<n>`. The `-v<n>` suffix lets you
publish a v2 alongside a v1 without breaking existing client configs.

## Worked example: Linear and Stripe

The configuration below exposes two upstream MCP servers — Linear and Stripe —
behind one Auth0-protected gateway. Each user authenticates once to the gateway,
then connects to Linear and Stripe independently the first time they call each.

### `zuplo.jsonc`

```jsonc
{
  "version": 1,
  "compatibilityDate": "2026-03-01",
}
```

### `modules/zuplo.runtime.ts`

```ts
import { RuntimeExtensions } from "@zuplo/runtime";
import { McpGatewayPlugin } from "@zuplo/runtime/mcp-gateway";

export function runtimeInit(runtime: RuntimeExtensions) {
  runtime.addPlugin(new McpGatewayPlugin());
}
```

### `config/policies.json`

```jsonc
{
  "policies": [
    {
      "name": "auth0-managed-oauth",
      "policyType": "mcp-auth0-oauth-inbound",
      "handler": {
        "module": "$import(@zuplo/runtime/mcp-gateway)",
        "export": "McpAuth0OAuthInboundPolicy",
        "options": {
          "auth0Domain": "$env(AUTH0_DOMAIN)",
          "clientId": "$env(AUTH0_CLIENT_ID)",
          "clientSecret": "$env(AUTH0_CLIENT_SECRET)",
        },
      },
    },
    {
      "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" },
        },
      },
    },
    {
      "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" },
        },
      },
    },
  ],
}
```

A few notes on what's set per upstream:

- **`protectedResourceMetadataUrl`** is explicit for Linear because Linear
  publishes its PRM at the root well-known path
  (`/.well-known/oauth-protected-resource`) instead of the per-route default
  (`/.well-known/oauth-protected-resource/mcp`). For Stripe the default works,
  so the option is omitted.
- **`scopes: []`** for Linear means the gateway falls back to the upstream's
  `WWW-Authenticate` `scope` value, then to the PRM's `scopes_supported`, then
  to no scope parameter. For Stripe the explicit `["mcp"]` is what the provider
  expects.
- **`clientRegistration: { mode: "auto" }`** lets the gateway register a client
  with each upstream on demand using OIDC Client ID Metadata Document discovery
  first, then RFC 7591 Dynamic Client Registration as a fallback. No client
  credentials need to live in source control.

### `config/routes.oas.json`

```jsonc
{
  "openapi": "3.1.0",
  "info": { "title": "MCP Gateway", "version": "0.1.0" },
  "paths": {
    "/mcp/linear-v1": {
      "get,post": {
        "operationId": "linear-mcp-server",
        "summary": "Linear MCP Proxy",
        "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"],
          },
        },
      },
    },
    "/mcp/stripe-v1": {
      "get,post": {
        "operationId": "stripe-mcp-server",
        "summary": "Stripe MCP Proxy",
        "x-zuplo-route": {
          "corsPolicy": "none",
          "handler": {
            "module": "$import(@zuplo/runtime/mcp-gateway)",
            "export": "McpProxyHandler",
            "options": { "rewritePattern": "https://mcp.stripe.com/mcp" },
          },
          "policies": {
            "inbound": ["auth0-managed-oauth", "mcp-token-exchange-stripe"],
          },
        },
      },
    },
  },
}
```

Once deployed (or running locally via `zuplo dev`), this gives clients two MCP
server URLs to add to their config:

- `https://<your-gateway>/mcp/linear-v1`
- `https://<your-gateway>/mcp/stripe-v1`

Both authenticate against the same Auth0 tenant; both produce one set of
analytics events distinguishable by `virtualServerName` and
`upstreamServerName`.

## Per-user state per upstream

Per-user upstream connections are keyed by
`(subjectId, upstreamServerId, authProfileId)`. With the configuration above:

- The first time a user calls `/mcp/linear-v1`, the gateway returns a JSON-RPC
  connect-required error with a URL pointing at
  `/auth/connections/linear/connect?...`. The user authorizes Linear, the
  gateway stores their encrypted Linear tokens, and the same call succeeds on
  retry.
- The same user calling `/mcp/stripe-v1` produces an independent
  connect-required for Stripe. Authorizing Linear does not grant access to
  Stripe and vice versa.
- A different user calling either route goes through the same flow with their
  own browser identity and their own per-upstream tokens.

The shared OAuth policy ties everything to one downstream session — the user
signs in to the gateway once and the browser cookie covers the consent step for
every new upstream they add. The upstream connections themselves are scoped per
`(user, upstream)` pair.

## Adding a per-route capability filter

To curate the tools a specific upstream exposes — say, restrict Linear to four
read tools — add a
[`mcp-capability-filter-inbound`](../../policies/mcp-capability-filter-inbound.mdx)
policy and attach it to one route's inbound chain:

```jsonc
// config/policies.json — add to the policies array
{
  "name": "filter-linear-read-only",
  "policyType": "mcp-capability-filter-inbound",
  "handler": {
    "module": "$import(@zuplo/runtime/mcp-gateway)",
    "export": "McpCapabilityFilterInboundPolicy",
    "options": {
      "tools": ["list_issues", "get_issue", "list_projects", "list_teams"],
    },
  },
}
```

Then update the Linear route's policy chain so the filter runs **after** the
token exchange policy (so its response hook sees the upstream's final
list-response, not an intermediate one):

```jsonc
"/mcp/linear-v1": {
  "get,post": {
    "operationId": "linear-mcp-server",
    "x-zuplo-route": {
      "policies": {
        "inbound": [
          "auth0-managed-oauth",
          "mcp-token-exchange-linear",
          "filter-linear-read-only"
        ]
      }
    }
  }
}
```

Only the four named tools appear in `tools/list` responses on `/mcp/linear-v1`.
Any `tools/call` for an unlisted tool returns a JSON-RPC `MethodNotFound` error
before the request reaches the upstream. The Stripe route is unaffected —
capability filters are per-route.

## Path and id conventions

The corp dogfood deployment uses these conventions, and they generalize well:

- **Route path**: `/mcp/<provider>-v<n>` — e.g., `/mcp/linear-v1`,
  `/mcp/stripe-v1`, `/mcp/notion-v1`.
- **`operationId`**: `<provider>-mcp-server` — e.g., `linear-mcp-server`,
  `stripe-mcp-server`.
- **Token-exchange policy name**: `mcp-token-exchange-<provider>` — the
  `<provider>` portion is what becomes the upstream `id` (and the
  `upstreamServerName` in analytics).
- **OAuth policy name**: pick one and reuse it; `auth0-managed-oauth` or
  `oidc-managed-oauth` are clear choices.

The `-v<n>` suffix on the route path matters more than it looks: it gives you a
clean upgrade path when an upstream provider releases a new MCP server URL with
breaking changes. Add a new `/mcp/linear-v2` route with a new token exchange
policy (and a new id), publish the v2 endpoint, migrate clients, then retire v1
once the last client is off it.

## Don't share an upstream id

The upstream `id` (either set explicitly via `options.id` or inferred from the
policy name) is the storage key for every user's upstream connection. Two
policies sharing one id is a configuration error, but the more subtle case is
**changing** an id on a policy that already has stored connections — every
existing user is silently disconnected because the new key doesn't match the old
storage path.

Pick the id once, document it, and treat it as part of the public contract of
the upstream just like the route path is part of the public contract of the
gateway.

## Next steps

- [`McpProxyHandler` reference](./mcp-proxy-handler.mdx) — the full handler
  contract.
- [Local development](./local-development.mdx) — run the multi-upstream
  configuration locally without setting up Auth0.
- [`mcp-token-exchange-inbound`](../../policies/mcp-token-exchange-inbound.mdx)
  — every per-upstream option, including manual client registration and
  shared-OAuth mode.
- [Connect MCP clients](../connect-clients/overview.mdx) — add multiple gateway
  routes to a single client config.
