ZuploZuplo
LoginSign Up
  • Documentation
  • API Reference
Introduction
Getting Started
    Develop using the Portal
      1 - Setup Your Gateway2 - Rate Limiting3 - API Key Auth4 - Deploy5 - Dynamic Rate LimitingMCP - Quick start
    Develop Locally
      1 - Setup Your Gateway2 - Rate Limiting3 - API Key Auth
Concepts
Development
Policies
Handlers
API Keys
MCP Server
MCP Gateway
    IntroductionBetaQuickstartQuickstart (Local Dev)How it works
    Connect MCP clients
    Authentication
    Configuration
      Set up the gatewayMulti-upstreamLocal developmentCapability filteringCurate toolsCurate tools (in code)McpProxyHandlerCompatibility dates
    Observability
    ReferenceTroubleshooting
AI Gateway
Developer Portal
Monetization
Deploying & Source Control
Observability
Networking & Infrastructure
Account Management
Programming API
Build with AI
Zuplo CLI
Migration Guides
Platform LimitsSecuritySupportTrust & ComplianceChangelog
powered by Zudoku
Configuration

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 which IdP wrapper you pick. 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 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

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

modules/zuplo.runtime.ts

Code
import { RuntimeExtensions } from "@zuplo/runtime"; import { McpGatewayPlugin } from "@zuplo/runtime/mcp-gateway"; export function runtimeInit(runtime: RuntimeExtensions) { runtime.addPlugin(new McpGatewayPlugin()); }

config/policies.json

Code
{ "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

Code
{ "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:

Code
// 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:

Code
"/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.

Related

  • McpProxyHandler reference — the full handler contract.
  • Local development — run the multi-upstream configuration locally without setting up Auth0.
  • Connect a gateway to an upstream OAuth provider — every per-upstream option, including manual client registration and shared-OAuth mode.
  • Curate the tools an upstream exposes — add a capability filter to one of the routes.
  • Connect MCP clients — add multiple gateway routes to a single client config.
Edit this page
Last modified on May 27, 2026
Set up the gatewayLocal development
On this page
  • The pattern
  • Worked example: Linear and Stripe
    • zuplo.jsonc
    • modules/zuplo.runtime.ts
    • config/policies.json
    • config/routes.oas.json
  • What each user sees on first connect
  • Adding a per-route capability filter
  • Path and id conventions
  • Don't share an upstream id
  • Related
JSON
TypeScript
JSON
JSON
JSON
JSON