# How the MCP Gateway works

The MCP Gateway is a Zuplo feature. A single deployment hosts any number of
public MCP routes (one per Virtual MCP), 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 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: 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 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. Stateful MCP
features (long-running subscriptions, server-initiated sampling) aren't
supported through the gateway today.

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

## Where the Portal vs. code config each piece

The Portal and code config edit the same underlying configuration. 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.

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