# Local development

The MCP Gateway runs the same way locally as any Zuplo project — `zuplo dev`,
port `9000`, hot reload on file changes. A few details are specific to the
gateway: the gateway prefers `127.0.0.1` over `localhost`, OAuth login can be
short-circuited entirely in dev, and the local `workerd` worker needs a full
restart after some MCP client connect attempts.

## Start the gateway

From the project root:

```bash
zuplo dev
```

The gateway listens at `http://127.0.0.1:9000`. Each MCP route in
`routes.oas.json` becomes reachable at that origin — for example
`http://127.0.0.1:9000/mcp/linear-v1`.

## Prefer `127.0.0.1` over `localhost`

OAuth metadata, callback URLs, and the in-dev login shortcut all key off the
request origin. Other loopback aliases (`localhost`, `::1`, `[::1]`) can cause
subtle OAuth issues in local dev.

When configuring an MCP client locally, use `127.0.0.1`:

```jsonc
// Good
"url": "http://127.0.0.1:9000/mcp/linear-v1"

// Avoid in local dev — works for most things, breaks subtly for OAuth
"url": "http://localhost:9000/mcp/linear-v1"
```

The same applies to any callback or redirect URI you configure with an identity
provider for local testing.

## Bypass your IdP with `/oauth/dev-login`

Setting up a real OIDC provider for local development is friction — you'd have
to register a localhost callback, manage test users, and so on. The gateway
exposes a loopback-only shortcut that skips the IdP round-trip entirely and
signs you in as a fixed `dev-browser-user` subject.

To use it, set `browserLogin.url` to the dev-login URL when configuring the
OAuth policy:

```jsonc
// config/policies.json — using the generic mcp-oauth-inbound policy
{
  "name": "dev-oauth",
  "policyType": "mcp-oauth-inbound",
  "handler": {
    "module": "$import(@zuplo/runtime/mcp-gateway)",
    "export": "McpOAuthInboundPolicy",
    "options": {
      "oidc": {
        "issuer": "http://127.0.0.1:9000",
        "jwksUrl": "http://127.0.0.1:9000/.well-known/jwks.json",
      },
      "browserLogin": {
        "url": "http://127.0.0.1:9000/oauth/dev-login",
      },
    },
  },
}
```

When `browserLogin.url` points at `/oauth/dev-login`, the
`browserLogin.tokenUrl`, `browserLogin.clientId`, and
`browserLogin.clientSecret` options aren't required. The consent page renders
normally.

:::caution

The `/oauth/dev-login` route returns `403 Forbidden` for any request that
doesn't arrive over loopback. It's not a security risk to leave configured for
production, but it's also not useful — production deployments should use a real
OIDC provider via `mcp-auth0-oauth-inbound` or `mcp-oauth-inbound`.

:::

A common pattern is keeping two OAuth policies in the project — one for
production (Auth0 or generic OIDC) and one for local dev — and selecting between
them in `routes.oas.json` based on the environment.

## Environment variables

When the OAuth policy reads from `$env(...)` references, define the values in a
`.env` file at the project root:

```bash
# .env

# Auth0 wrapper interpolations
AUTH0_DOMAIN=your-tenant.us.auth0.com
AUTH0_CLIENT_ID=your-auth0-web-app-client-id
AUTH0_CLIENT_SECRET=your-auth0-web-app-client-secret

# Optional: the audience the gateway requires on issued tokens
AUTH0_AUDIENCE=https://mcp-gateway.example.com
```

`.env` is read at `zuplo dev` startup. Restart the dev server after adding or
changing an environment variable.

Never commit `.env` to source control. Instead, check in a `.env.example` (or
`env.example`) that documents which variables are required and an
empty/placeholder value for each.

## Adding the gateway to a local MCP client

Once `zuplo dev` is running and the route is reachable, add the gateway URL to
your MCP client config the same way you'd add any other remote MCP server. For
example, with Claude Desktop:

```jsonc
// claude_desktop_config.json
{
  "mcpServers": {
    "linear-via-zuplo-local": {
      "url": "http://127.0.0.1:9000/mcp/linear-v1",
    },
  },
}
```

The client triggers the gateway's OAuth flow on first connect. With
`/oauth/dev-login` configured, the browser tab opens, lands on the consent page
without any IdP login, and you connect each upstream through its normal browser
OAuth flow. Subsequent calls reuse the issued tokens until they expire.

See [Connect MCP clients](../connect-clients/overview.mdx) for client-specific
snippets and the connect URL format.

## When `zuplo dev` crashes after a connect attempt

Some MCP client connect attempts can leave the local dev server in a state where
hot reload no longer recovers it. If the dev server stops responding after an
MCP client connects — particularly after browser OAuth callbacks finish — fully
restart `zuplo dev`:

```bash
# Stop zuplo dev with Ctrl+C
# Start it again
zuplo dev
```

Then have the MCP client reconnect. A restart doesn't force a re-consent — your
upstream tokens are still stored.

This is a known dev-only quirk and doesn't affect deployed gateways.

## Verifying the gateway is up

Two quick checks that don't require an MCP client:

**Fetch the well-known OAuth metadata for a route.** The path follows the
route's `operationId`:

```bash
curl http://127.0.0.1:9000/.well-known/oauth-protected-resource/mcp/linear-v1
```

A correct response is JSON with `resource`, `authorization_servers`,
`bearer_methods_supported`, and `scopes_supported` fields.

**Send a POST without a token.** The gateway should return `401` with a
`WWW-Authenticate` header pointing at the Protected Resource Metadata URL:

```bash
curl -i -X POST http://127.0.0.1:9000/mcp/linear-v1 \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
```

If you see the 401 plus the challenge, the OAuth policy is wired up correctly.
The next call from a real client will then start the OAuth dance.

## Next steps

- [`McpProxyHandler` reference](./mcp-proxy-handler.mdx) — the route handler the
  gateway uses for proxying.
- [Compatibility dates](./compatibility-dates.mdx) — pin `2026-03-01` in
  `zuplo.jsonc`.
- [Multi-upstream pattern](./multi-upstream.mdx) — one project, many upstreams.
- [Connect MCP clients](../connect-clients/overview.mdx) — wire each client to
  the local or deployed gateway URL.
