# Troubleshooting

This page covers the issues people hit most often with the MCP Gateway,
organized by symptom. Each entry lists what you'll see, the likely cause, and
the fix.

## 401 with no WWW-Authenticate header

**Symptom.** The MCP client gets a `401 Unauthorized` response when it first
tries `POST /v1/mcp/<slug>`, but the response has no `WWW-Authenticate: Bearer`
header. The client never discovers the authorization server.

**Likely cause.** The route in `routes.oas.json` doesn't have an MCP OAuth
policy in its inbound chain. Without `mcp-oauth-inbound` or
`mcp-auth0-oauth-inbound`, the route returns a plain 401 from another policy or
handler that doesn't speak the MCP authorization spec.

**Fix.** Add the OAuth policy to the route's `policies.inbound` array. For
Portal-managed projects, the Catalog page handles this automatically;
double-check that the project's identity provider is configured. For code-config
projects:

```jsonc
"policies": {
  "inbound": ["auth0-managed-oauth", "mcp-token-exchange-linear"]
}
```

A single MCP OAuth policy can (and should) be shared across every MCP route in
the project.

## 403 with `error="insufficient_scope"`

**Symptom.** An authenticated request to a Virtual MCP returns 403 with
`WWW-Authenticate: Bearer error="insufficient_scope", scope="mcp:tools", resource_metadata=...`.

**Likely cause.** The access token was issued for a different scope set than the
gateway expects. The only scope the gateway issues is `mcp:tools`. A DCR client
registered with a different scope, or a stale token from a previous
configuration, will fail this check.

**Fix.** Re-register the client and let it discover the scope through the AS
metadata document, or use step-up authorization to re-issue the token with
`scope=mcp:tools`. For MCP clients that support incremental scope consent, the
gateway's 403 response includes the required scope in `WWW-Authenticate` for the
client to re-request.

## Connect-required errors

**Symptom.** The first tool call after authentication returns a JSON-RPC error
with `code: -32042` and a payload containing `"state": "authenticating"` and an
`authUrl`. The MCP client either opens a browser tab (modern clients) or
surfaces the URL for the user to copy (older clients).

**Likely cause.** This isn't an error — it's the gateway asking the user to
complete the upstream OAuth flow for the first time. Each (user, upstream) pair
connects once; after that, requests skip this step entirely.

**Fix.** Open the `authUrl` in a browser and complete the upstream provider's
login. The page returns to the gateway, which stores the encrypted tokens, and
the next tool call succeeds.

## Reconsent prompts

**Symptom.** A user who connected an upstream weeks ago suddenly sees a
connect-required error again, this time with `"state": "reconsent_required"` and
a message like "Linear authorization must be renewed."

**Likely cause.** The upstream provider revoked the gateway's OAuth client, or
the user revoked the connection from the upstream's dashboard, or the refresh
token expired according to the upstream's policy. The stored connection record
still exists, but its tokens are no longer usable.

**Fix.** Follow the same flow as a first-time connect — the user re-authorizes
the upstream and the gateway replaces the stored tokens. No admin action is
required.

## Compatibility date too old

**Symptom.** Calls work most of the time, but a request that triggers an
upstream 401 (for example, an upstream token expired mid-session) returns the
raw 401 to the client instead of refreshing and retrying.

**Likely cause.** `compatibilityDate` in `zuplo.jsonc` is older than
`2026-03-01`. The upstream-auth retry hook depends on chained response- hook
semantics that landed on that date.

**Fix.** Bump the compatibility date:

```jsonc
{
  "compatibilityDate": "2026-03-01",
}
```

Then redeploy. See the [reference](./reference.mdx#compatibility-date) page for
context.

## Auth0 policy rejects domain with "https://" prefix

**Symptom.** The runtime rejects the project's MCP Auth0 policy with a
configuration error that mentions an invalid `auth0Domain` value.

**Likely cause.** `McpAuth0OAuthInboundPolicy` requires a bare hostname — not a
URL. Passing `https://my-tenant.us.auth0.com/` fails validation.

**Fix.** Set `auth0Domain` to just the hostname:

```jsonc
{
  "options": {
    "auth0Domain": "my-tenant.us.auth0.com",
    "clientId": "$env(AUTH0_CLIENT_ID)",
    "clientSecret": "$env(AUTH0_CLIENT_SECRET)",
  },
}
```

The policy derives the full issuer and JWKS URLs internally.

## Custom domain → wrong issuer in AS metadata

**Symptom.** The Authorization Server metadata document advertises an issuer
like `https://my-project.zuplosite.com` instead of your custom domain
(`https://api.example.com`). MCP clients fail audience validation because the
token's `iss` doesn't match the URL they expect.

**Likely cause.** The reverse proxy or CDN in front of the gateway isn't
propagating the original `Host` header, and isn't setting `X-Forwarded-Host`
either. The gateway derives its issuer from the incoming request's effective
host.

**Fix.** Configure your edge proxy to forward one of these:

- Preserve the original `Host` header end-to-end.
- Or set `X-Forwarded-Host: api.example.com` on requests to the gateway.

The gateway uses `X-Forwarded-Host` when present, falling back to `Host`. After
updating the proxy, fetch
`https://api.example.com/.well-known/oauth-authorization-server` and confirm the
`issuer` field matches.

## "Reload Tools" is disabled in the Portal

**Symptom.** The "Reload Tools" menu item on Origin MCP and Virtual MCP cards is
greyed out and shows a keyboard shortcut but doesn't respond to clicks.

**Likely cause.** This action isn't wired up yet — the menu item is a
placeholder for a future feature.

**Fix.** To pick up a change to an upstream's tool list, delete and recreate the
Origin MCP. Tools are fetched at the moment the origin is created. Automatic
refresh is on the roadmap.

## Dev server needs a restart after the first MCP client connects

**Symptom.** When running `zuplo dev` locally, the first MCP client connection
succeeds, but subsequent requests hang or return unexpected errors. Restarting
`zuplo dev` makes it work again.

**Likely cause.** The local workerd dev runtime sometimes gets into a state
where it doesn't cleanly hand off long-lived connections after the first MCP
client attaches.

**Fix.** When in doubt, restart `zuplo dev`. This is a local development quirk
only — the production runtime is unaffected.

## GET on `/v1/mcp/<slug>` returns 405

**Symptom.** A client (or a browser, or a probe) sends `GET /v1/mcp/linear-prod`
and gets back `405 Method Not Allowed` with `Allow: POST` and a message about
Streamable HTTP.

**Likely cause.** This is by design. The gateway implements the Streamable HTTP
transport as POST-only and doesn't open SSE streams for server-initiated
messages.

**Fix.** Use `POST` for all MCP requests. Browser-based health checks or uptime
monitors should hit a different endpoint — pointing them at the well-known PRM
URL (`/.well-known/oauth-protected-resource/<path>`) is a good lightweight
option.

## Tool name doesn't match in capability filter

**Symptom.** A capability-filter policy lists a tool by name, but the upstream
still appears to return everything (or returns nothing).

**Likely cause.** The filter matches **case-sensitively and exactly**. A typo, a
stray space, or a capitalization difference makes the entry not match, and the
policy treats the tool as if it weren't on the allow-list.

**Fix.** Run `tools/list` against the unfiltered upstream first and copy the
`name` exactly. For example:

```sh
curl -X POST https://my-gateway.zuplo.dev/v1/mcp/linear-prod \
  -H 'Authorization: Bearer <token>' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":"1","method":"tools/list"}'
```

Then paste the exact `name` values into the policy's `tools` array.

## Browser session expires after 8 hours

**Symptom.** A long-running MCP client that's been idle for most of a day
suddenly redirects the user to the identity provider's login page the next time
it makes a request.

**Likely cause.** The `__mcp_session` cookie has an 8-hour default lifetime.
Once it expires, the next interaction that hits the consent or login flow has to
re-authenticate the user against the IdP.

**Fix.** Either accept the re-login (this matches a typical workday) or extend
the session by setting `browserLogin.sessionTtlSeconds` on the OAuth policy to a
longer value. The trade-off is the usual one: longer sessions mean a longer
window where a stolen cookie is useful.

## Per-route policies are unavailable in the Portal

**Symptom.** The "Select Policies" popover on each Virtual MCP card in the
Portal shows "Policies will be available soon" instead of a policy picker.

**Likely cause.** Portal UI for per-Virtual-MCP policies is still in
development.

**Fix.** Use code-config for now. Add the policy you need (rate limiting,
capability filtering, request validation, and so on) to the route's
`policies.inbound` array in `routes.oas.json`. The Portal- managed route and a
hand-written policy reference can coexist on the same route.

## Where to look when none of the above match

- Open the project's **Logs** in the Portal and filter on the request's
  `zuplo-request-id`. Gateway events are emitted with a `<route>_<verb>` naming
  convention (`oauth_token_responded`, `upstream_connect_received`, and so on)
  and include the relevant `operationId` and `subjectId`.
- Open the **Analytics** dashboard and switch to the **MCP** tab. The "Top
  Reason Codes" and "Failure Origins" panels surface the highest- cardinality
  failure modes for the current time window.
- For OAuth-specific issues, the
  [MCP Inspector](https://github.com/modelcontextprotocol/inspector) reproduces
  the full discovery and authorization flow against any gateway URL and gives a
  step-by-step view of where it breaks.
