# McpProxyHandler reference

`McpProxyHandler` is the route handler that backs every MCP Gateway route. It
accepts stateless Streamable HTTP requests over POST, forwards them to the
configured upstream MCP server using Zuplo's standard URL rewrite, and emits a
pair of analytics events per request so the gateway dashboard knows what each
capability call did.

## When to use it

Use `McpProxyHandler` on any route that proxies to an upstream MCP server. Pair
it with at least one MCP OAuth policy on the inbound chain; add an
`mcp-token-exchange-inbound` policy when the upstream itself requires OAuth, and
optionally `mcp-capability-filter-inbound` to curate what the upstream
advertises.

If the upstream uses a static API key or static header instead of OAuth, keep
the MCP OAuth policy on the route, drop the token exchange policy, and add
[`set-upstream-api-key-inbound`](../../policies/set-upstream-api-key-inbound.mdx)
or [`set-headers-inbound`](../../policies/set-headers-inbound.mdx) to attach the
credential before the handler forwards.

## Configuration

The handler is referenced from the route's `x-zuplo-route.handler` block in
`routes.oas.json`:

```jsonc
"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"
    ]
  }
}
```

Set `corsPolicy` to `"none"`. MCP clients aren't browser-based and shouldn't be
sending ambient credentials.

## Options

### `rewritePattern` (required)

The upstream MCP server URL. The handler forwards each authenticated POST to
this URL, with hop-by-hop headers stripped and the resolved upstream
`Authorization: Bearer` header applied by the token exchange policy.

Two value forms are supported:

- **A literal HTTPS or HTTP URL.** Stored verbatim and used as the upstream
  target.
- **An environment-variable reference of the form `${env.X}`.** The variable is
  resolved at config-load time and must be a fully-qualified HTTP(S) URL.

```jsonc
// Literal URL
{ "rewritePattern": "https://mcp.linear.app/mcp" }

// Environment variable
{ "rewritePattern": "${env.UPSTREAM_MCP_URL}" }
```

Dynamic request-based patterns (anything other than the two forms above) are
explicitly rejected. The MCP token exchange policy needs a stable upstream
identity to discover Protected Resource Metadata and key the per-user OAuth
state, and a per-request URL breaks that.

:::caution

The URL Rewrite handler's broader template syntax — `${params.x}`,
`${headers.get("x")}`, and so on — is **not** supported on `rewritePattern` for
MCP routes. Use a literal URL or an `${env.X}` reference.

:::

### `forwardSearch` (optional)

Type: `boolean`. Default: `true`.

When `true`, the inbound request's query string is appended to the upstream URL
before forwarding. Set to `false` to drop client query parameters.

### `followRedirects` (optional)

Type: `boolean`. Default: `false`.

When `false`, redirects from the upstream return as-is to the client (status
code and `Location` header passed through). Set to `true` to have the runtime
follow them transparently.

### `mtlsCertificate` (optional)

Type: `string`. The id of an mTLS certificate registered with the Zuplo project.
When set, the upstream fetch uses mutual TLS with the specified client
certificate. Most MCP upstreams don't require mTLS; leave this unset unless you
specifically need it.

## Behavior

### GET returns 405

The gateway only speaks stateless Streamable HTTP, and the MCP authorization
spec uses POST for every JSON-RPC call. A `GET` to an MCP route returns:

```http
HTTP/1.1 405 Method Not Allowed
Allow: POST
Content-Type: application/problem+json

{
  "type": "https://httpproblems.com/http-status/405",
  "status": 405,
  "detail": "MCP Gateway routes support stateless Streamable HTTP requests over POST. Server-sent event GET streams are not supported."
}
```

If you've seen an MCP server that exposes a GET endpoint for SSE event streams,
that's a different transport. The Zuplo MCP Gateway is Streamable HTTP,
POST-only.

### POST forwards to the upstream

A POST request runs through the inbound policy chain, then the handler:

1. **Reads the request body** so capability-level analytics can parse the
   JSON-RPC method and capability name before forwarding.
2. **Emits a `capability_invocation_started` event** when the body contains a
   recognizable JSON-RPC call (`tools/call`, `prompts/get`, `resources/read`,
   etc.).
3. **Forwards the request** via Zuplo's standard
   [`urlRewriteHandler`](../../handlers/url-rewrite.mdx) using the resolved
   `rewritePattern`, the inbound body, and the mutated headers produced by the
   policy chain.
4. **Emits the final capability event** after the upstream response is in hand —
   populated with `outcome`, `mcpStatus`, `latencyMs`, and the JSON-RPC error
   details when present.

### Response hooks run in order

The token exchange policy installs two response-sending hooks before the handler
runs:

- An **upstream-401 retry hook** that refreshes the upstream credential and
  retries the upstream fetch once if the original response was a 401. It picks
  the new `scope=` value out of the upstream's `WWW-Authenticate` header if one
  is present.
- An **initialize-icon injection hook** that adds a fallback favicon to
  `initialize` responses whose `serverInfo.icons` array is empty.

The retry hook depends on chained response-hook semantics that require
`compatibilityDate >= 2026-03-01`. See
[Compatibility dates](./compatibility-dates.mdx).

The capability filter policy, if attached, installs a third response hook that
filters and projects `tools/list`, `prompts/list`, `resources/list`, and
`resources/templates/list` payloads. It must come **after**
`mcp-token-exchange-inbound` in the policy chain so the retry hook has the
final-source-of-truth response to work with.

### Hop-by-hop headers are stripped

Standard HTTP/1.1 hop-by-hop headers — `connection`, `keep-alive`,
`proxy-authenticate`, `proxy-authorization`, `te`, `trailer`,
`transfer-encoding`, `upgrade`, `host`, `content-length` — are removed at the
policy boundary before the upstream fetch. The runtime sets the correct values
itself.

### The inbound `Authorization` header is dropped

The gateway-issued bearer token is consumed by the MCP OAuth policy and stripped
from the request before it reaches the upstream. The token exchange policy then
sets the upstream's own `Authorization: Bearer <upstream-token>` header. The MCP
client's token never leaves the gateway.

## Route requirements

The route registry enforces three rules on every route that uses
`McpProxyHandler`:

- **`operationId` is required.** It's the stable route identity for OAuth
  audience binding, storage, analytics, and connect URLs. Routes without an
  `operationId` are silently skipped at config-load.
- **The inbound policy chain must include an MCP OAuth policy** — either
  `mcp-oauth-inbound` or `mcp-auth0-oauth-inbound`. Without one, the route is
  registered but unreachable as an MCP endpoint.
- **At most one MCP token exchange policy per route.** Two
  `mcp-token-exchange-inbound` policies on the same route is a configuration
  error.

Across the project:

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

## Analytics

Every POST emits two analytics events when the request body parses as a JSON-RPC
call:

- A **`capability_invocation_started`** event fired before the upstream fetch,
  carrying the parsed `mcpMethod` and `capabilityName`.
- A **`capability_invocation_completed`** event fired after the response,
  carrying `outcome`, `mcpStatus`, `latencyMs`, and any JSON-RPC error details.

Each event also includes the route's `operationId` (as `virtualServerName`), the
upstream `id` (as `upstreamServerName`), the authenticated `subjectId`, the
`authProfileId`, and the `upstreamAuthMode`. See
[Analytics](../observability/analytics.mdx) for the dashboard view and
[Logging](../observability/logging.mdx) for the structured-log counterpart.

## Related

- [`urlRewriteHandler`](../../handlers/url-rewrite.mdx) — the standard Zuplo
  handler that `McpProxyHandler` delegates to for the actual upstream fetch.
- `mcp-token-exchange-inbound` — installs the upstream credential and the
  401-retry response hook.
- `mcp-capability-filter-inbound` — curates the upstream surface area on a
  per-route basis.
- [Multi-upstream pattern](./multi-upstream.mdx) — pair one `McpProxyHandler`
  route with each upstream MCP server in one project.
