ZuploZuplo
LoginStart for Free
  • Documentation
  • API Reference
Getting Started
Concepts
API Management
AI Gateway
MCP Gateway
    Overview
    Getting Started
      How it works
      Connect MCP clients
      Authentication
      Cross App Access
      Configuration
        Set up the gatewayMulti-upstreamLocal developmentCapability filteringCurate toolsCurate tools (in code)McpProxyHandlerCompatibility dates
      Policies
      Observability
      ReferenceServer RegistryTroubleshooting
    MCP Server
    Developer Portal
    Development
    Deploying & Source Control
    Analytics
    Observability
    Networking & Infrastructure
    Account Management
    Programming API
    Build with AI
    Zuplo CLI
    Migration Guides
    Platform LimitsVersion Support PolicySecuritySupportTrust & ComplianceChangelog
    powered by Zuplo
    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" }, }, "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 June 5, 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