Configuration

Set up an MCP Gateway

To turn any Zuplo project into an MCP Gateway, configure four things in source control: the runtime plugin in modules/zuplo.runtime.ts, one MCP OAuth policy in config/policies.json, one mcp-token-exchange-inbound policy per OAuth-protected upstream, and one route per upstream in config/routes.oas.json. This guide walks through each piece for a single-upstream gateway.

For the conceptual model — what each piece does and why the pieces are split the way they are — see How the MCP Gateway works.

1. Register the MCP Gateway plugin

Add a modules/zuplo.runtime.ts file that registers McpGatewayPlugin:

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

The plugin registers the OAuth metadata, authorization endpoints, consent page, and upstream connect callbacks the gateway needs. It also serves the read-only MCP Server Registry by default. See the McpGatewayPlugin reference for all configuration options. The plugin is a no-op when no MCP-related policy is present, so adding it to projects that don't yet use the gateway has zero runtime cost.

2. Define one OAuth policy

The OAuth policy authenticates inbound MCP requests against your identity provider. Pick the first-class wrapper for your IdP — the provider catalog lists every supported IdP. The Auth0 case looks like this:

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

Match your environment variable names to the $env(...) references in policies.json.

Each wrapper takes a small set of provider-specific options (a domain, a tenant ID, a subdomain, and so on) and derives the OIDC URLs from them. For IdPs without a dedicated wrapper — Ory Hydra, Authentik, FusionAuth, PingFederate, a custom OIDC server — use the generic mcp-oauth-inbound policy. See Configuring a generic OIDC provider for the worked example.

Attach the same MCP OAuth policy to every MCP route in the project. Attaching different OAuth policies causes a configuration error.

3. Define one token-exchange policy per upstream

Each OAuth-protected upstream gets its own mcp-token-exchange-inbound policy:

JSONconfig/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" }, }, }, }

Name each policy mcp-token-exchange-<id>. The id after the prefix identifies the upstream in analytics and connect URLs. Changing the id strands any existing user-to-upstream connections, so pick it once and keep it.

For per-mode reference and worked examples per provider, see Connect a gateway to an upstream OAuth provider.

4. Define one route per upstream

Each upstream gets a route in routes.oas.json. The handler points at the upstream URL; the inbound policy chain attaches the OAuth policy followed by the matching token exchange policy:

JSONconfig/routes.oas.json
{ "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"], }, }, }, }, }, }

The path is yours to choose — /mcp/<provider>-v<n> is the recommended convention because it makes the path self-describing and reserves room for versioned upgrades, but the gateway works with any path the OpenAPI router accepts.

get,post is Zuplo's multi-method shorthand. The handler rejects GET with 405 Method Not Allowed because the gateway only speaks stateless Streamable HTTP over POST — see McpProxyHandler for the full handler reference.

Every MCP route must set operationId. Across the project, no two MCP routes can share an operationId or a path, and no two mcp-token-exchange-* policies can share an upstream id. If operationId is missing or duplicated, the gateway returns a configuration error on the first matching request.

Verify the gateway is wired up

Start the project with zuplo dev, or npm run dev if the project's dev script runs that command. Then send an unauthenticated request to the route:

TerminalCode
curl -i -X POST http://127.0.0.1:9000/mcp/linear-v1 \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"tools/list","id":1}'

The gateway should return 401 Unauthorized with a WWW-Authenticate header that points at the Protected Resource Metadata URL. If you see that, the OAuth policy is wired up correctly. See Local development for the dev-loop specifics, including the loopback-only login shortcut that skips your IdP during development.

Add more upstreams

The pattern is the same for each additional upstream: one MCP OAuth policy stays attached across the project, and one mcp-token-exchange-* policy and one route get added per new upstream MCP server. Per-user state is keyed by (subjectId, upstreamServerId), so each user maintains independent connections to each upstream they consent to.

For a worked example with two upstreams and the full file layout, see Add multiple upstream MCP servers.

Last modified on