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
-
Declare one
mcp-token-exchange-inboundpolicy per upstream MCP server inconfig/policies.json:config/policies.json -
Attach the policy to the route in
config/routes.oas.json, after the inbound MCP OAuth policy:config/routes.oas.json
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 theclientIddirectly 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
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
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
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):
- Connect a test client (the MCP Inspector is the fastest option) to the route as a fresh user.
- 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. - Complete the upstream provider's OAuth flow in the browser. The gateway stores the resulting tokens encrypted, keyed by the user's subject ID.
- 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
The corresponding route:
config/routes.oas.json
Stripe (explicit scope)
config/policies.json
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
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 —
McpProxyHandlerproxies through directly.
Related
- Per-user OAuth to upstream MCP servers — the conceptual model behind the policy.
McpProxyHandlerreference — 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
curlfor low-level verification.