# How the MCP Gateway works

The MCP Gateway is a set of policies and a route handler that run inside any
Zuplo project. A single deployment hosts any number of public MCP routes, each
pointing at a different upstream MCP server. The gateway runs its own OAuth 2.1
authorization server for inbound clients and acts as an OAuth client to each
upstream provider.

## Request lifecycle

The diagram below shows a first-time call from an MCP client to a route that
wires a single OAuth-protected upstream. Once tokens are issued and the upstream
connection exists, the gateway skips the OAuth dance and goes straight from the
bearer-token check to the upstream proxy.

```mermaid
sequenceDiagram
  autonumber
  participant Client as MCP Client
  participant Gateway as MCP Gateway
  participant IdP as Identity Provider
  participant Upstream as Upstream MCP Server

  Client->>Gateway: POST /mcp/&lt;name&gt; (no token)
  Gateway-->>Client: 401 WWW-Authenticate: Bearer resource_metadata=...
  Client->>Gateway: GET /.well-known/oauth-protected-resource/&lt;path&gt;
  Gateway-->>Client: PRM (lists authorization_servers)
  Client->>Gateway: GET /.well-known/oauth-authorization-server/&lt;path&gt;
  Gateway-->>Client: AS metadata
  Client->>Gateway: POST /oauth/register (DCR) or use CIMD client_id
  Gateway-->>Client: client_id
  Client->>Gateway: GET /oauth/authorize/&lt;path&gt; (PKCE + resource)
  Gateway->>IdP: Browser redirect to login
  IdP-->>Gateway: Callback with code
  Gateway-->>Client: Render consent + upstream connect page
  Client->>Upstream: Browser OAuth (per upstream)
  Upstream-->>Gateway: Callback, encrypted tokens stored
  Client->>Gateway: Approve consent
  Gateway-->>Client: Redirect with authorization code
  Client->>Gateway: POST /oauth/token (code + PKCE verifier)
  Gateway-->>Client: access_token (scope mcp:tools)
  Client->>Gateway: POST /mcp/&lt;name&gt; with Bearer token
  Gateway->>Gateway: Validate token, look up upstream credential
  Gateway->>Upstream: Forward request with upstream Bearer
  Upstream-->>Gateway: Response
  Gateway-->>Client: Response
```

Notes on the flow:

- The 401 response always includes
  `WWW-Authenticate: Bearer resource_metadata=...` so spec-compliant clients can
  discover the Protected Resource Metadata document without a fallback probe.
- The `resource` parameter (RFC 8707) is mandatory on `/oauth/authorize` and
  `/oauth/token`. The gateway rejects tokens whose audience doesn't match the
  canonical URI of the route they're being used against.
- The consent screen is server-rendered HTML, served at `/oauth/setup`. It lists
  the upstream the requested route depends on with a per-upstream **Connect**
  button. The user can't approve the gateway grant until the required upstream
  has been connected.
- The upstream OAuth flow runs once per (user, upstream) pair. Subsequent
  requests reuse the stored encrypted tokens. If an upstream returns a 401
  mid-call, the gateway refreshes the upstream token and retries once before
  propagating the error.

## Two OAuth surfaces

The gateway plays two OAuth roles simultaneously, and it's important to keep
them straight.

### Downstream — gateway as OAuth 2.1 server

The gateway implements the MCP authorization spec from the perspective of a
Resource Server and an Authorization Server. MCP clients talk OAuth to the
gateway, not to the upstream providers. Standards observed:

- **RFC 8414** Authorization Server Metadata and **OpenID Connect Discovery
  1.0** for AS discovery.
- **RFC 9728** Protected Resource Metadata for advertising the AS.
- **RFC 7591** Dynamic Client Registration and **OAuth Client ID Metadata
  Documents** (CIMD) for client registration. CIMD is the recommended path; DCR
  is supported for clients that don't speak it.
- **RFC 7636** PKCE with S256 required.
- **RFC 8707** Resource Indicators — the `resource` parameter is required on
  every authorization and token request.
- **RFC 6750** Bearer tokens — the gateway issues opaque tokens carried in
  `Authorization: Bearer` headers.

The gateway delegates user authentication to a configured OIDC identity provider
(Auth0 through `McpAuth0OAuthInboundPolicy` or generic OIDC through
`McpOAuthInboundPolicy`). The provider's tokens never leave the gateway — the
gateway issues its own opaque access tokens, scoped to `mcp:tools`, and binds
each to one specific MCP route.

Token passthrough is explicitly forbidden by the spec, and the gateway enforces
it: inbound auth headers don't leak to the upstream.

### Upstream — gateway as OAuth client

For each upstream MCP server that requires OAuth, the gateway acts as a standard
OAuth client.

- **Per-user OAuth (`authMode: "user-oauth"`)** — every end user goes through a
  one-time consent. The gateway stores their access and refresh tokens encrypted
  at rest, keyed by user. Token refresh is automatic.
- **Shared OAuth (`authMode: "shared-oauth"`)** — one upstream connection shared
  across every user of the gateway. The connection is established by an
  administrator through a special connect flow.

Client registration with the upstream supports two modes:

- `clientRegistration: { mode: "auto" }` (the default) — the gateway publishes a
  per-upstream OAuth Client ID Metadata Document at
  `/.well-known/oauth-client/<connection>` and tells the upstream that URL is
  the `client_id`. If the upstream doesn't support CIMD, the gateway falls back
  to RFC 7591 Dynamic Client Registration.
- `clientRegistration: { mode: "manual" }` — supply a pre-registered `clientId`
  and `clientSecret` (and optional auth method).

When the gateway needs an upstream connection it doesn't have yet, the gateway
returns a JSON-RPC error with a URL to open in a browser. Modern MCP clients pop
the browser automatically; older ones surface the URL for the user to open
manually.

## Transport — Streamable HTTP, POST only

Every MCP route uses the
[Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports)
defined in the MCP spec. The gateway accepts POST requests only:

- `POST /mcp/<name>` carries the JSON-RPC payload.
- `GET /mcp/<name>` returns `405 Method Not Allowed` with `Allow: POST`. The
  gateway doesn't open SSE streams for server-initiated messages.

The gateway is **stateless**. It does not maintain MCP sessions, doesn't track
subscriptions, and doesn't emit server-initiated notifications. Stateful MCP
features (long-running subscriptions, server-initiated sampling) aren't
supported through the gateway today.

## Configuration model

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 shape:

| Piece                                                | Lives in                   | Purpose                                                                                                                                |
| ---------------------------------------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `compatibilityDate >= 2026-03-01`                    | `zuplo.jsonc`              | Unlocks MCP Gateway features. Required.                                                                                                |
| `McpGatewayPlugin`                                   | `modules/zuplo.runtime.ts` | Registers the OAuth metadata, authorization endpoints, consent page, and upstream connect callbacks.                                   |
| One MCP OAuth policy                                 | `config/policies.json`     | Authenticates inbound MCP requests against your identity provider. One per project (`mcp-auth0-oauth-inbound` or `mcp-oauth-inbound`). |
| One `mcp-token-exchange-inbound` policy per upstream | `config/policies.json`     | Resolves the user's upstream credential and attaches it as the upstream `Authorization` header. Omit for non-OAuth upstreams.          |
| Optional `mcp-capability-filter-inbound` policy      | `config/policies.json`     | Curates the tools, prompts, resources, and resource templates the route exposes.                                                       |
| One route per upstream                               | `config/routes.oas.json`   | Uses `McpProxyHandler` with the upstream URL as `rewritePattern`. Attaches the OAuth policy + token exchange policy.                   |

A minimal route looks like this:

```jsonc title="config/routes.oas.json"
"/mcp/linear-v1": {
  "get,post": {
    "operationId": "linear-mcp-server",
    "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 plugin registration:

```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 `operationId` on each MCP route is more than a label — it identifies the MCP
route and is the `virtualServerName` in analytics. Changing it strands all
stored tokens and per-user upstream connections.

:::caution

MCP Gateway features require `compatibilityDate >= 2026-03-01` in `zuplo.jsonc`.
See [Compatibility dates](./code-config/compatibility-dates.mdx).

:::

### Inbound policy chain

For each request to an MCP route, the policies run in this order:

1. **MCP OAuth policy** (`mcp-auth0-oauth-inbound` or `mcp-oauth-inbound`) —
   validates the gateway-issued bearer token, asserts audience binding and
   scope.
2. **MCP token-exchange policy** (`mcp-token-exchange-inbound`) — resolves the
   right upstream credential for the authenticated user. If the user hasn't
   connected this upstream yet, the policy returns a connect-required error.
3. **Capability filter policy** (`mcp-capability-filter-inbound`, optional) —
   filters the upstream's `tools/list`, `prompts/list`, `resources/list`, and
   `resources/templates/list` responses, and blocks calls to hidden capabilities
   with `MethodNotFound`.

The handler — `McpProxyHandler` — runs after the policies, forwards the request
to the upstream URL, and emits capability analytics events.

## What the gateway does not do

A few capabilities are intentionally out of scope, at least today:

- **No stateful sessions.** The gateway doesn't open SSE streams, doesn't track
  `MCP-Session-Id`, and doesn't proxy server-initiated requests.
- **No `tools/list` caching.** Every request goes upstream. If an upstream is
  slow to list capabilities, callers feel it.
- **No prompt-injection or PII scanning at the policy level.** These belong in a
  separate inbound policy and can be composed alongside the MCP policies through
  Zuplo's standard policy model.
- **No rate limiting on OAuth endpoints out of the box.** Add Zuplo's built-in
  `rate-limit-inbound` policy to those routes if needed.

## Next steps

- [Quickstart](./quickstart.mdx) — add the MCP Gateway plugin to a Zuplo project
  and front your first upstream.
- [Reference](./reference.mdx) — the full URL catalog, default TTLs,
  compatibility date, and OAuth metadata extensions.
- [Troubleshooting](./troubleshooting.mdx) — the gotchas that catch most people
  the first time.
