MCP Token Exchange Policy
MCP Gateway Policy
This policy is for use with the MCP Gateway. See the MCP Gateway documentation to learn how to proxy and secure MCP servers with Zuplo.
Resolve gateway-managed upstream MCP credentials and apply them to the request.
Use this after gateway auth when the upstream requires Zuplo-managed
credentials: per-user OAuth, shared OAuth, or ID-JAG (Cross-App Access) token
exchange. Omit it for public upstreams or upstreams handled by ordinary Zuplo
header/API-key policies. The route should use McpProxyHandler with the
upstream URL configured on the handler.
Configuration
The configuration shows how to configure the policy in the 'policies.json' document.
Code
Policy Configuration
name<string>- The name of your policy instance. This is used as a reference in your routes.policyType<string>- The identifier of the policy. This is used by the Zuplo UI. Value should bemcp-token-exchange-inbound.handler.export<string>- The name of the exported type. Value should beMcpTokenExchangeInboundPolicy.handler.module<string>- The module containing the policy. Value should be$import(@zuplo/runtime).handler.options<object>- The options for this policy. See Policy Options below.
Policy Options
The options for this policy are specified below. All properties are optional unless specifically marked as required.
id<string>- Stable id for the upstream connection. Used to namespace per-user OAuth state and audit events. If omitted, the gateway tries to infer it from the policy name (mcp-token-exchange-{id}).displayName(required)<string>- Display name shown in connect-required responses, audit logs, and the setup UI.summary<string>- Optional human-readable summary of the upstream, shown on the consent page.authMode(required)<string>- Authentication mode.user-oauthperforms per-user OAuth federation;shared-oauthuses a gateway-wide OAuth grant;id-jagexchanges the caller's identity assertion at the configured IdP for an ID-JAG (Identity Assertion JWT Authorization Grant, also known as Cross-App Access / XAA), then redeems it at the upstream Resource Authorization Server for an upstream access token. Allowed values areuser-oauth,shared-oauth,id-jag.scopes<string[]>- OAuth scopes to request from the upstream (for OAuth modes).protectedResourceMetadataUrl<string>- Optional override for the upstream's OAuth protected-resource metadata URL. Defaults from the route handler's rewritePattern.scopeDelimiter<string>- Delimiter used to join scopes in the OAuth authorization request. Defaults to a single space.prompt<undefined>- Overrides the OIDCpromptparameter on upstream authorization requests. The MCP SDK addsprompt=consentwhenever the requested scope includesoffline_access, which forces a consent screen on every authorization on providers like Microsoft Entra ID even after a tenant admin has granted org-wide admin consent. Leave unset to keep the SDK's behavior; set tofalseto strip thepromptparameter; set an explicit value to replace it. Warning: strippingprompt=consentwhile requestingoffline_accesscan silently prevent refresh-token issuance on providers that require a fresh consent grant for offline access (e.g. Google) — only use this when the provider grants offline access via pre-authorized/admin consent (e.g. Entra tenant admin consent).clientRegistration<undefined>- OAuth client registration mode. Defaults toauto, which uses Client ID Metadata Documents when the upstream advertises support and falls back to Dynamic Client Registration otherwise.idJag<object>- ID-JAG (Cross-App Access / XAA) token-exchange configuration. Required whenauthModeisid-jag; not used by the OAuth modes.scopes<string[]>- Scopes to request on the ID-JAG token exchange at the IdP. Defaults to[].scopeDelimiter<string>- Delimiter used to join scopes in the token-exchange request. Defaults to a single space. Defaults to" ".idp(required)<object>- The identity provider that exchanges the caller's identity assertion for an ID-JAG.tokenUrl(required)<string>- The IdP token endpoint used for the ID-JAG token exchange.clientAuth(required)<undefined>- Client authentication used at the token endpoint.
resourceAs(required)<object>- The upstream Resource Authorization Server that redeems the ID-JAG for an upstream access token.tokenUrl(required)<string>- The Resource Authorization Server token endpoint where the gateway redeems the ID-JAG via the jwt-bearer grant.audience(required)<string>- Theaudiencevalue sent on the ID-JAG token exchange, identifying the Resource Authorization Server (typically its issuer).resource<string>- Optionalresourceindicator sent on the token exchange, identifying the upstream MCP resource. Defaults to the upstream MCP server URL from the route handler's rewritePattern.clientAuth(required)<undefined>- Client authentication used at the token endpoint.
Using the Policy
Overview
The mcp-token-exchange-inbound policy resolves gateway-managed upstream MCP
credentials and applies them to the request before the normal Zuplo route
handler forwards it.
Use this policy only when Zuplo manages upstream credentials: per-user OAuth
(user-oauth), a gateway-wide shared OAuth grant (shared-oauth), or ID-JAG
token exchange (id-jag). If the upstream is public, uses an API key header, or
only needs static routing/context headers, omit this policy and compose the
existing Zuplo header/auth policies instead.
Zuplo is only the gateway. It discovers the upstream MCP server, sends users through the upstream OAuth flow, stores the resulting upstream connection, and adds the upstream credential before forwarding tool traffic. It does not invent provider scopes, register provider apps on your behalf when the provider blocks registration, or hide provider setup failures behind a generic gateway error.
The policy does not perform the normal upstream fetch and does not pass hidden
context to the handler. The route should use McpProxyHandler with a
deterministic upstream MCP URL configured on the handler. McpProxyHandler
handles GET stream probes and delegates POST forwarding to Zuplo's
urlRewriteHandler. The policy only installs a response hook for MCP OAuth
retry/connect-required cases.
Projects using this policy must run with a compatibility date that enables
chained response hooks, currently 2026-03-01 or later. The retry hook must
receive the latest response in the policy chain so later response hooks cannot
accidentally replace an upstream OAuth retry or connect-required response.
Configuration
Code
The upstream MCP server URL comes from the route handler's rewritePattern, the
same place McpProxyHandler uses when forwarding traffic.
Scope Selection
Set scopes when the upstream provider requires specific OAuth scopes that are
not discoverable from the MCP challenge or protected resource metadata. Some
providers reject an otherwise valid authorization request when scope is empty
or incomplete.
When scopes is omitted or empty, the gateway uses the first scope source it
can discover:
- The upstream
WWW-Authenticatechallengescopevalue. - The upstream protected resource metadata
scopes_supportedvalue. - No
scopeparameter if the upstream does not advertise one.
Explicit configured scopes always win: they are sent as the scope parameter on
the upstream authorization request even when the upstream advertises a different
scopes_supported in its protected resource metadata. Use them for providers
such as Microsoft 365 where the correct resource-specific application scope is
known from the provider configuration rather than from MCP discovery, or to add
offline_access when the upstream requires it for refresh-token issuance but
does not advertise it.
Consent Prompt Override
The MCP SDK that the gateway uses as its upstream OAuth client adds
prompt=consent to the authorization request whenever the requested scope
includes offline_access, because most providers require a fresh consent grant
before issuing a refresh token. The gateway itself never injects prompt; the
prompt option strips or replaces the SDK's parameter at the gateway's
authorization seam. Some providers honor prompt=consent literally: Microsoft
Entra ID forces the consent screen on every single authorization, even after a
tenant admin has granted org-wide admin consent.
Set prompt to control this behavior:
- Unset (default): keep the SDK's behavior, including the automatic
prompt=consentforoffline_access. false: strip thepromptparameter from the authorization request. Use this for Entra upstreams where a tenant admin has already granted admin consent — users connect without seeing the consent screen, and refresh tokens are still issued because admin consent satisfies the requirement."none"/"login"/"consent"/"select_account": set or replace thepromptparameter with an explicit value.
Note that the effective scope can include offline_access even when scopes is
not configured, because the gateway also reads scopes from the upstream's
WWW-Authenticate challenge and protected resource metadata.
Warning: stripping prompt=consent while requesting offline_access can
silently prevent refresh-token issuance on providers that require a fresh
consent grant for offline access (for example, Google). Only use false when
the provider grants offline access through pre-authorized or admin consent, such
as Entra tenant admin consent. If connections start requiring frequent
reauthorization after setting prompt: false, remove the option so the consent
grant is requested again.
ID-JAG Token Exchange (Cross-App Access)
Set authMode to id-jag when the upstream is protected by an Identity
Assertion JWT Authorization Grant (ID-JAG) Resource Authorization Server, the
pattern also known as Cross-App Access (XAA). Instead of sending users through
the upstream's browser OAuth flow, the gateway obtains the upstream credential
in two server-to-server steps:
- IdP token exchange: the gateway exchanges the caller's identity assertion
at the configured identity provider's token endpoint for an ID-JAG, sending
the configured
audience(the Resource Authorization Server) andresource(the upstream MCP server). - Resource AS redemption: the gateway redeems the ID-JAG at the upstream
Resource Authorization Server's token endpoint using the
jwt-bearergrant and receives the upstream access token it attaches to the forwarded request.
Use this mode in enterprise deployments where the gateway and the upstream trust
the same identity provider (for example, Okta Cross-App Access), so upstream
access follows the caller's identity without a per-user upstream consent flow.
The user-oauth/shared-oauth options — scopes, scopeDelimiter, prompt,
and clientRegistration — do not apply; the id-jag mode is configured
entirely through the idJag option.
Code
Both clientAuth blocks also accept client_secret_basic and
private_key_jwt. Use private_key_jwt for providers such as Okta that require
a signed client assertion for token exchange; it takes privateKeyPem, an
optional keyId, and an algorithm (default RS256). Source secrets and
private keys from environment variables with $env(VAR_NAME). When resource
is omitted, the gateway defaults it to the upstream MCP server URL from the
route handler's rewritePattern.
Route Shape
Publish both MCP transport methods as one Zuplo multi-method operation using
get,post. POST is the stateless Streamable HTTP route that forwards to the
upstream. GET uses the same route and returns 405 Method Not Allowed with
Allow: POST from McpProxyHandler before upstream dispatch.
Code
Troubleshooting Upstream OAuth
Gateway authorization errors fall into three buckets:
| Bucket | What it means | What to fix |
|---|---|---|
| Gateway configuration | The route or policy options are invalid before the gateway can contact the upstream. | Fix policies.json or routes.oas.json. The error should name the broken entry. |
| Upstream OAuth setup | The upstream MCP server requires provider/admin setup that the gateway cannot complete automatically. | Configure the provider app, allowlist redirect URIs, add required scopes, or contact the provider to approve the client. |
| Upstream service response | The upstream server returned its own error page or OAuth error. | Treat the upstream response as the source of truth and fix the upstream URL, allowlist, account region, or provider configuration. |
For browser-based OAuth failures, the gateway error page shows a user-friendly message and visible developer details. Developer details include the gateway error code, request id, and the underlying reason so screenshots are useful in support tickets.
If the upstream returns an HTML error response, such as an edge firewall
403 Access Denied page, the gateway displays that upstream HTML response on
the error page instead of replacing it with only a generic gateway message. This
usually means the upstream URL is not publicly reachable from the gateway or the
provider has not allowed this client/network/account to access the MCP endpoint.
Common examples:
- Provider requires app approval or allowlisting: the upstream may reject DCR/CIMD or only allow registered clients. Configure a provider OAuth app or contact the provider to approve the client.
- Provider requires explicit scopes: add the provider-required scopes to
scopes. Do not rely on gateway inference when the provider does not publish the required values. - Wrong or private upstream URL: if a direct probe of the upstream MCP URL
returns an HTML
403,404, or branded provider error before OAuth discovery, fix therewritePattern/metadata URL or provider access. The gateway cannot make a private or blocked upstream public. - No upstream auth: omit this policy for anonymous MCP servers. A public
upstream should route through
McpProxyHandlerwithout token exchange.
Read more about how policies work