# 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` policy lives per upstream; one route per upstream
wires them together.

This page is a worked example: a single gateway project that exposes Linear and
Stripe as two separate MCP endpoints, with the full `zuplo.jsonc`,
`policies.json`, `routes.oas.json`, and runtime-init files you can copy into
your own project.

## 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) identifies the upstream — pick it once and don't
   change it.
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`.

## What each user sees on first connect

A user only signs in to the gateway once. From there, each upstream needs its
own one-time connect:

- The first time the user calls `/mcp/linear-v1`, the client opens a browser to
  authorize Linear. The next call succeeds.
- Calling `/mcp/stripe-v1` for the first time produces a separate browser prompt
  for Stripe. Authorizing Linear doesn't grant access to Stripe.

Each user's connection to each upstream is independent — one user authorizing
Linear has no effect on any other user.

## 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` 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:

```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) identifies each user's upstream connection. Two policies sharing
one id is a configuration error, and **changing** an id on a policy that already
has stored connections silently disconnects every existing user.

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` — 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.
