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:
- One MCP OAuth policy, project-wide. The gateway allows exactly one MCP
OAuth policy per project, regardless of variant (
mcp-auth0-oauth-inboundormcp-oauth-inbound). Every MCP route attaches the same policy. - One
mcp-token-exchange-*policy per upstream. Each upstream MCP server gets its own policy with its owndisplayName,authMode,scopes, and optionalprotectedResourceMetadataUrl. The policy'sid(or theidinferred from its name) identifies the upstream — pick it once and don't change it. - One
/mcp/<slug>route per upstream. Each route usesMcpProxyHandlerwith the upstream URL asrewritePattern, 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
Code
modules/zuplo.runtime.ts
Code
config/policies.json
Code
A few notes on what's set per upstream:
protectedResourceMetadataUrlis 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'sWWW-Authenticatescopevalue, then to the PRM'sscopes_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
Code
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-v1https://<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-v1for 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:
Code
Then update the Linear route's policy chain so the filter runs after the token exchange policy:
Code
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 upstreamid(and theupstreamServerNamein analytics). - OAuth policy name: pick one and reuse it;
auth0-managed-oauthoroidc-managed-oauthare 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
McpProxyHandlerreference — the full handler contract.- Local development — 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 — add multiple gateway routes to a single client config.