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

<Diagram height="h-72">
  <DiagramNode id="client">MCP Client</DiagramNode>
  <DiagramGroup id="gateway" label="Zuplo MCP Gateway">
    <DiagramNode id="oauth" variant="zuplo">
      OAuth endpoints
    </DiagramNode>
    <DiagramNode id="route" variant="zuplo">
      MCP route
    </DiagramNode>
  </DiagramGroup>
  <DiagramNode id="idp">Identity Provider</DiagramNode>
  <DiagramNode id="upstream">Upstream MCP Server</DiagramNode>
  <DiagramEdge from="client" to="oauth" label="Discover + auth" />
  <DiagramEdge from="oauth" to="idp" label="Browser login" />
  <DiagramEdge from="client" to="route" label="Bearer token" />
  <DiagramEdge from="route" to="upstream" label="Proxied request" />
</Diagram>

The flow in detail, for the first call from a new client to an OAuth-protected
upstream:

1. The client POSTs to the MCP route with no token.
2. The gateway returns `401` with
   `WWW-Authenticate: Bearer resource_metadata=...`.
3. The client fetches the Protected Resource Metadata document and discovers the
   gateway's authorization server.
4. The client fetches the AS metadata, registers via DCR (or uses a CIMD client
   ID), and starts the authorization flow with PKCE and a `resource` parameter.
5. The gateway redirects the user's browser to the configured identity provider
   for login.
6. After login, the gateway renders a consent page that lists every upstream the
   route requires.
7. The user completes upstream OAuth for each required upstream — the gateway
   stores per-user tokens encrypted at rest.
8. The user approves consent. The gateway redirects the client back with an
   authorization code.
9. The client exchanges the code at `/oauth/token` and receives an access token
   scoped to `mcp:tools`.
10. The client POSTs to the MCP route with the bearer token. The gateway
    validates the token, attaches the user's upstream credential, and proxies to
    the upstream MCP server.

Once tokens are issued and the upstream connection exists, only step 10 runs on
subsequent calls.

A few details worth knowing:

- The `resource` parameter (RFC 8707) is required on `/oauth/authorize` and
  `/oauth/token`. The gateway rejects tokens whose audience doesn't match the
  route they're being used against.
- The consent screen lists the upstream the route depends on with a **Connect**
  button. The user can't approve the grant until the upstream is connected.
- The upstream OAuth flow runs once per (user, upstream) pair. Subsequent
  requests reuse the stored credential. If an upstream returns a 401 mid-call,
  the gateway refreshes 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.
