# How the MCP Gateway works

The MCP Gateway is a Zuplo runtime feature. A single deployment hosts any number
of public MCP routes (one per Virtual MCP), each pointing at a different
upstream MCP server. Underneath, the gateway runs its own OAuth 2.1
authorization server for inbound clients and acts as an OAuth client to each
upstream provider. Every durable piece of state — issued tokens, upstream
connections, pending consents — lives in a Zuplo-managed storage backend, so the
edge runtime stays stateless.

## Request lifecycle

The diagram below shows a first-time call from an MCP client to a Virtual MCP
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 /v1/mcp/&lt;slug&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 /v1/mcp/&lt;slug&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 Virtual MCP they're being used against.
- The consent screen is server-rendered HTML, served at `/oauth/setup`. It lists
  every upstream the requested Virtual MCP depends on with per- upstream
  **Connect** buttons. The user can't approve the gateway grant until every
  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 Virtual MCP.

Token passthrough is explicitly forbidden by the spec, and the gateway enforces
it: the inbound `Authorization` header is stripped before any upstream request
goes out.

### 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 subject. The MCP SDK's client provider handles token
  refresh in process.
- **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 proxy
policy returns a JSON-RPC error wrapping a `UrlElicitationRequiredError` that
carries the 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 Virtual 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 /v1/mcp/<slug>` carries the JSON-RPC payload.
- `GET /v1/mcp/<slug>` 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. The gateway
forwards each POST to the upstream as an independent request once the bearer
token and upstream credential are resolved.

This trade-off keeps the gateway easy to scale horizontally and avoids the
sticky-session requirement that stateful Streamable HTTP usually imposes on load
balancers. Stateful MCP features (long-running subscriptions, server-initiated
sampling) aren't supported through the gateway today.

## Storage model

The edge runtime owns no database connection. Every durable piece of state —
issued access tokens, refresh tokens, pending authorization transactions,
browser sessions, DCR-registered clients, per-user upstream connections, OAuth
state tokens — is read and written through an HTTP storage backend managed by
the Zuplo platform.

What lives in storage:

- **Downstream OAuth state.** Access tokens and refresh tokens are stored by
  SHA-256 hash; the gateway never persists the raw token value. Refresh tokens
  rotate on every use; replaying an old refresh token revokes the entire grant
  (with a short concurrent-refresh grace window).
- **DCR-registered clients.** Public and confidential clients registered through
  `/oauth/register`, with a 90-day TTL.
- **Browser sessions.** The `__mcp_session` cookie carries a signed JWT; the
  session record itself lives in storage with an 8-hour TTL by default.
- **Per-user upstream connections.** The upstream's access and refresh tokens,
  encrypted with a key derived from the project secret.
- **Pending authorization transactions.** The state stored between
  `/oauth/authorize` and `/oauth/token`, including the PKCE code challenge and
  the user's per-upstream consent decisions.

You don't need to provision or migrate any storage. The Zuplo platform manages
it.

## Configuration model

Each Virtual MCP is a route in `routes.oas.json` that uses `McpProxyHandler` as
its handler. The route declares one inbound OAuth policy and (when the upstream
needs auth) one token-exchange policy. A single project shares one OAuth policy
across all of its MCP routes.

A minimal route looks like this:

```jsonc
"/v1/mcp/linear-prod": {
  "post": {
    "operationId": "linear-prod-mcp",
    "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 OAuth and token-exchange policies live in `config/policies.json`. The plugin
that registers the OAuth and upstream-callback endpoints lives in
`modules/zuplo.runtime.ts`:

```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 — the gateway uses it
as the stable identity for the route's resource binding, its upstream credential
storage key, and its analytics dimension. Changing it strands all stored tokens
and per-user upstream connections.

:::caution

The MCP Gateway requires `compatibilityDate` of `2026-03-01` or later in
`zuplo.jsonc`. Older dates lack the chained response-hook semantics the
upstream-auth retry logic depends on, which causes the gateway to fail to
recover from upstream 401s mid-request.

:::

### Inbound policy chain

For each request to a Virtual MCP, 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, sets `request.user`, and strips the inbound `Authorization` header so
   it can't leak upstream.
2. **MCP token-exchange policy** (`mcp-token-exchange-inbound`) — resolves the
   right upstream credential for the authenticated user and attaches it as the
   new `Authorization` header. If the user hasn't connected this upstream yet,
   the policy short-circuits with a `UrlElicitationRequiredError`.
3. **Capability filter policy** (`mcp-capability-filter-inbound`, optional) —
   installs a response hook that 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, emits a
capability-invoked analytics event, forwards the request via the runtime's
URL-rewrite handler, then emits the capability-final analytics event.

## Where the Portal vs. code config each piece

Both surfaces can coexist — the Portal manages records in storage that the
runtime reads at request time, and direct edits to `routes.oas.json` and
`policies.json` use the same runtime configuration model. Pick whichever fits
how your team wants to manage gateway configuration.

| Surface                                    |         Portal         |                         Code config                          |
| ------------------------------------------ | :--------------------: | :----------------------------------------------------------: |
| Create the gateway project                 |          Yes           |                              No                              |
| Add an Origin MCP (upstream URL)           |          Yes           |                    Yes (`rewritePattern`)                    |
| Custom headers / API-key auth to upstream  |           —            |        Yes (compose `SetHeadersInboundPolicy`, etc.)         |
| Create a Virtual MCP (slug + URL)          |          Yes           |                    Yes (route definition)                    |
| Curate which tools the Virtual MCP exposes |   Yes (tool picker)    |            Yes (`mcp-capability-filter-inbound`)             |
| Override tool descriptions / annotations   |           —            |              Yes (capability filter projection)              |
| Configure the downstream OAuth IdP         | Yes (Auth0 onboarding) | Yes (`McpAuth0OAuthInboundPolicy` / `McpOAuthInboundPolicy`) |
| Configure per-upstream OAuth               |           —            |            Yes (`McpTokenExchangeInboundPolicy`)             |
| Teams and Virtual MCP assignment           |          Yes           |                              —                               |
| Analytics dashboard                        |          Yes           |                          Read-only                           |

Most teams pick one path per project to keep change tracking consistent, but
mixing is supported — Portal-managed routes appear alongside hand-written ones,
and the runtime treats them identically.

## 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) — set everything above up in the Portal in ten
  minutes.
- [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.
