# Set up an MCP Gateway

To turn any Zuplo project into an MCP Gateway, configure five things in source
control: the compatibility date in `zuplo.jsonc`, 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](../how-it-works.mdx).

## 1. Pin the compatibility date

MCP Gateway features require `compatibilityDate >= 2026-03-01` in `zuplo.jsonc`:

```jsonc title="zuplo.jsonc"
{
  "version": 1,
  "compatibilityDate": "2026-03-01",
}
```

New Zuplo projects default to a recent compatibility date, so this only applies
to existing projects being upgraded to use the MCP Gateway. See
[Compatibility dates](./compatibility-dates.mdx) for details.

## 2. Register the MCP Gateway plugin

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

```ts title="modules/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's 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.

## 3. Define one OAuth policy

The OAuth policy authenticates inbound MCP requests against your identity
provider. Pick one of two variants based on the IdP.

For Auth0:

```jsonc title="config/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)",
    },
  },
}
```

For any other OIDC provider (Okta, Microsoft Entra ID, Cognito, Keycloak, etc.),
use the generic `mcp-oauth-inbound` policy with explicit `oidc.*` and
`browserLogin.*` options. See [Configuring Okta](../auth/configuring-okta.mdx)
for a worked example.

:::caution

A project can have only one MCP OAuth policy. The gateway rejects any
configuration with two, regardless of variant. The same policy is attached to
every MCP route in the project — every route authenticates against the same
identity provider.

:::

## 4. Define one token-exchange policy per upstream

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

```jsonc title="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" },
    },
  },
}
```

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](../how-to/connect-upstream-oauth.mdx).

## 5. 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:

```jsonc title="config/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`](./mcp-proxy-handler.mdx) 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` and the gateway is reachable at
`http://127.0.0.1:9000/mcp/linear-v1`. A quick sanity check is to send an
unauthenticated POST:

```bash
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](./local-development.mdx)
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
shared 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](./multi-upstream.mdx).

## Related

- [`McpProxyHandler` reference](./mcp-proxy-handler.mdx) — every option and
  every behavior of the route handler.
- [Compatibility dates](./compatibility-dates.mdx) — why `2026-03-01` is
  required and what older dates break.
- [Local development](./local-development.mdx) — dev-loop, loopback URLs, the
  `/oauth/dev-login` shortcut, and the `workerd` restart quirk.
- [Add multiple upstream MCP servers](./multi-upstream.mdx) — one project, many
  upstream MCP servers.
- [Curate the tools an upstream exposes](../how-to/curate-tools.mdx) — restrict
  and re-project the tools, prompts, and resources a route exposes.
