# Configuring the MCP Gateway

The MCP Gateway is configured the same way as the rest of a Zuplo project: an
OpenAPI route file, a policy library, and a runtime plugin registration. Every
project that uses the gateway has the same four pieces in source control.

This page shows the four pieces every Zuplo project needs to act as an MCP
Gateway, then walks through a minimal single-upstream example. Once that works,
the remaining pages in this section cover the
[handler reference](./mcp-proxy-handler.mdx), the
[multi-upstream pattern](./multi-upstream.mdx),
[local development](./local-development.mdx), and the
[compatibility date requirement](./compatibility-dates.mdx).

## What you can configure

Every runtime option is available in code config — version-controlled in your
repo, reviewed through pull requests, and deployed through your existing CI/CD
pipelines:

- **Capability filtering.** Use the `mcp-capability-filter-inbound` policy to
  allow-list tools, prompts, resources, and resource templates per route, and to
  rewrite the descriptions and annotations the upstream advertises.
- **Manual upstream OAuth client registration.** Set
  `clientRegistration: { mode: "manual" }` on `mcp-token-exchange-inbound` with
  a pre-registered `clientId` (and optional `clientSecret`) when your
  organization manages OAuth apps centrally, when the upstream provider requires
  a specific approved client, or whenever Dynamic Client Registration and OIDC
  Client ID Metadata Documents aren't an option.
- **Composing with other Zuplo policies.** Add
  [`set-headers-inbound`](../../policies/set-headers-inbound.mdx),
  [`set-upstream-api-key-inbound`](../../policies/set-upstream-api-key-inbound.mdx),
  [`rate-limit-inbound`](../../policies/rate-limit-inbound.mdx), or any custom
  policy to an MCP route. Anything that runs in the standard Zuplo request
  pipeline composes with `McpProxyHandler`.
- **Shared-OAuth upstream connections.** Use `authMode: "shared-oauth"` on the
  token exchange policy when one upstream credential serves all users on the
  route.
- **Protected Resource Metadata overrides.** Some upstream MCP servers publish
  their PRM at a non-default path; the token exchange policy's
  `protectedResourceMetadataUrl` option overrides the derived default.

## The four required pieces

Every Zuplo project that acts as an MCP Gateway wires up four things.

### 1. Pin the compatibility date

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

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

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
// 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 in `policies.json`

The OAuth policy authenticates inbound MCP requests against your identity
provider and turns the project into an OAuth-protected MCP resource.

Use `mcp-auth0-oauth-inbound` when the identity provider is Auth0:

```jsonc
// 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.

:::caution

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

:::

### 4. Define one `mcp-token-exchange-*` policy per upstream

Each upstream MCP server gets its own `mcp-token-exchange-inbound` policy. The
policy resolves the user's upstream credential and attaches it as an
`Authorization: Bearer` header before the gateway proxies the request:

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

Naming convention: 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.

### 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
// 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 (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
behavior.

## The `operationId` requirement

Every MCP route **must** set `operationId`. It identifies the MCP route and
appears as the `virtualServerName` in analytics events.

Uniqueness rules:

- No two MCP routes can share an `operationId`.
- No two MCP routes can share a path.
- 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.

## Putting it all together

A minimal project with one OAuth provider and one upstream MCP server has
exactly these files in source control:

```text
.
├── config/
│   ├── policies.json
│   └── routes.oas.json
├── modules/
│   └── zuplo.runtime.ts
├── .env
├── package.json
└── zuplo.jsonc
```

With:

- `zuplo.jsonc` pinning the compatibility date.
- `modules/zuplo.runtime.ts` registering `McpGatewayPlugin`.
- `config/policies.json` declaring one OAuth policy and one token exchange
  policy.
- `config/routes.oas.json` exposing one `/mcp/<slug>` route that wires the two
  policies onto `McpProxyHandler`.
- `.env` (not committed) holding `AUTH0_DOMAIN`, `AUTH0_CLIENT_ID`,
  `AUTH0_CLIENT_SECRET`, and any other upstream-specific environment variables.

Start the project with `zuplo dev` and the gateway is reachable at
`http://127.0.0.1:9000/mcp/linear-v1`. See
[Local development](./local-development.mdx) for the dev-loop specifics,
including the loopback-only login shortcut that skips your IdP during
development.

## Adding more upstreams

The pattern is the same: one MCP OAuth policy stays shared across the project,
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 file layout, see
[Add multiple upstream MCP servers](./multi-upstream.mdx).

## Next steps

- [`McpProxyHandler` reference](./mcp-proxy-handler.mdx) — every option, 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, environment variables, the workerd restart quirk.
- [Multi-upstream pattern](./multi-upstream.mdx) — one project, many upstream
  MCP servers.
- `mcp-capability-filter-inbound` — restrict and re-project the tools, prompts,
  and resources a route exposes.
