# Configuring the MCP Gateway with code

The Zuplo Portal covers the day-to-day MCP Gateway workflow — Origins, Virtual
MCPs, tool curation, teams, and analytics. For everything else, the 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.

This page shows the four pieces every code-configured MCP Gateway project needs,
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).

## When to use code config

Reach for code config when you want the full Zuplo configuration model — every
runtime option, 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 Portal and code config edit the same underlying configuration. A project can
mix both, but most teams pick one path per project to keep review and change
tracking consistent.

## The four required pieces

Every code-configured MCP Gateway project 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"],
          },
        },
      },
    },
  },
}
```

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