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
      OverviewUpstream OAuthConnect an upstream OAuth provider
      Identity providers
      Manual OAuth testing
    Configuration
    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
Authentication

Connect a gateway to an upstream OAuth provider

When an upstream MCP server requires OAuth — either per user or as a shared service account — attach the mcp-token-exchange-inbound policy to the route. The policy resolves the user's upstream credential and applies it to the upstream request, returns a connect-required error when the user hasn't yet authorized the upstream, and refreshes the credential transparently.

For the conceptual model behind the policy — the two auth modes, client registration, the consent flow, and connect-required states — see Per-user OAuth to upstream MCP servers.

Add the token-exchange policy

  1. Declare one mcp-token-exchange-inbound policy per upstream MCP server in config/policies.json:

    config/policies.json
    { "name": "mcp-token-exchange-linear", "policyType": "mcp-token-exchange-inbound", "handler": { "module": "$import(@zuplo/runtime/mcp-gateway)", "export": "McpTokenExchangeInboundPolicy", "options": { "displayName": "Linear", "protectedResourceMetadataUrl": "https://mcp.linear.app/.well-known/oauth-protected-resource", "authMode": "user-oauth", "scopes": [], "clientRegistration": { "mode": "auto" }, }, }, }
  2. Attach the policy to the route in config/routes.oas.json, after the inbound MCP OAuth policy:

    config/routes.oas.json
    "policies": { "inbound": ["auth0-managed-oauth", "mcp-token-exchange-linear"] }

Only one MCP token-exchange policy is allowed per route. The route's upstream URL comes from McpProxyHandler's rewritePattern option, not from the policy.

Compatibility date 2026-03-01

MCP Gateway features require compatibilityDate >= 2026-03-01 in zuplo.jsonc. See Compatibility dates.

Pick an auth mode

Set authMode based on who owns the upstream credential:

  • "user-oauth" — each user has their own per-upstream OAuth connection. This is the default and the right choice for Linear, Notion, Stripe, GitHub, and most SaaS MCP servers.
  • "shared-oauth" — one gateway-wide OAuth grant used by every user. An administrator completes a one-time connection; subsequent user requests reuse the shared credential. Pick shared mode when the upstream uses a service account that represents the organization rather than individual users.

Pick a client registration mode

Set clientRegistration based on how the gateway should identify itself to the upstream OAuth provider:

  • { "mode": "auto" } (default) — the gateway publishes a per-upstream OAuth Client ID Metadata Document and tells the upstream that URL is the client ID. If the upstream doesn't accept CIMD, the gateway falls back to RFC 7591 Dynamic Client Registration. No upstream client credentials live in source control.
  • { "mode": "manual", "clientId": "...", "clientSecret": "...", "tokenEndpointAuthMethod": "client_secret_basic" } — pre-registered OAuth app. The gateway uses the clientId directly and authenticates to the upstream token endpoint with the configured method. Pick manual mode when your organization manages OAuth client lifecycle centrally, when the upstream requires an approved client, or when you need to share one OAuth client across multiple routes.

Use $env(...) for clientSecret so the secret stays out of source control.

Set scopes when the upstream needs them

When the upstream requires specific scopes that aren't discoverable from MCP metadata, set scopes explicitly:

Code
{ "options": { "scopes": ["mcp"], }, }

When scopes is omitted or empty, the gateway falls back through the upstream's most recent WWW-Authenticate challenge, then the scopes_supported array in Protected Resource Metadata, then no scope parameter at all. Microsoft 365, Slack, PostHog, Stripe, Grafana Cloud, and several other providers fall into the bucket where explicit scopes are required.

Override the Protected Resource Metadata URL

By default, the gateway derives the upstream PRM URL from the route's rewritePattern:

Code
rewritePattern: https://mcp.linear.app/mcp default PRM URL: https://mcp.linear.app/.well-known/oauth-protected-resource/mcp

When the upstream serves PRM at a non-default path, override it with protectedResourceMetadataUrl. Linear, for example, serves PRM at the origin's root, not under /mcp:

Code
{ "options": { "displayName": "Linear", "protectedResourceMetadataUrl": "https://mcp.linear.app/.well-known/oauth-protected-resource", "authMode": "user-oauth", "clientRegistration": { "mode": "auto" }, }, }

When in doubt, look at what the upstream's MCP endpoint returns in its WWW-Authenticate header on an unauthenticated request — the resource_metadata= parameter on that header is the canonical URL.

Test the connect flow

After deploying (or restarting zuplo dev):

  1. Connect a test client (the MCP Inspector is the fastest option) to the route as a fresh user.
  2. The first MCP request returns a JSON-RPC connect-required error with an authUrl. Modern MCP clients open the URL automatically; older clients surface it for the user to copy.
  3. Complete the upstream provider's OAuth flow in the browser. The gateway stores the resulting tokens encrypted, keyed by the user's subject ID.
  4. The next MCP request succeeds. Subsequent requests reuse the stored credential transparently.

For deeper debugging — including a manual curl walkthrough of the OAuth flow — see Manual OAuth testing.

Worked examples

Linear (auto registration, PRM override)

config/policies.json
{ "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" }, }, }, }

The corresponding route:

config/routes.oas.json
"/mcp/linear-v1": { "get,post": { "operationId": "linear-mcp-server", "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"] } } } }

Stripe (explicit scope)

config/policies.json
{ "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" }, }, }, }

Stripe requires the bare mcp scope explicitly. The default PRM URL (derived from the route's rewritePattern of https://mcp.stripe.com/mcp) is correct, so no override is needed.

Notion (PRM override at /mcp path)

config/policies.json
{ "name": "mcp-token-exchange-notion", "policyType": "mcp-token-exchange-inbound", "handler": { "module": "$import(@zuplo/runtime/mcp-gateway)", "export": "McpTokenExchangeInboundPolicy", "options": { "displayName": "Notion", "protectedResourceMetadataUrl": "https://mcp.notion.com/.well-known/oauth-protected-resource/mcp", "authMode": "user-oauth", "scopes": [], "clientRegistration": { "mode": "auto" }, }, }, }

Non-OAuth upstreams

mcp-token-exchange-inbound only handles OAuth. For other credential shapes, omit this policy and compose ordinary Zuplo policies alongside McpProxyHandler:

  • API key in a custom header: use set-upstream-api-key-inbound.
  • Static request headers: use SetHeadersInboundPolicy.
  • Anonymous upstream: no upstream credential policy is needed — McpProxyHandler proxies through directly.

Related

  • Per-user OAuth to upstream MCP servers — the conceptual model behind the policy.
  • McpProxyHandler reference — the route handler the token-exchange policy attaches credentials for.
  • Add multiple upstream MCP servers — apply the same pattern across several upstreams in one project.
  • Manual OAuth testing — drive the upstream OAuth surface with curl for low-level verification.
Edit this page
Last modified on May 27, 2026
Upstream OAuthAuth0
On this page
  • Add the token-exchange policy
  • Pick an auth mode
  • Pick a client registration mode
  • Set scopes when the upstream needs them
  • Override the Protected Resource Metadata URL
  • Test the connect flow
  • Worked examples
    • Linear (auto registration, PRM override)
    • Stripe (explicit scope)
    • Notion (PRM override at /mcp path)
  • Non-OAuth upstreams
  • Related
JSON
JSON
JSON
JSON
JSON
JSON
JSON
JSON