# Local development

Use `zuplo dev` to run the MCP Gateway locally on port `9000` with hot reload.
For local testing, you can sign in without setting up an identity provider.

## Start the gateway

Start the development server from the project root:

```bash
zuplo dev
```

If the project has the standard `dev` script, `npm run dev` runs the same
command.

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

## Use one loopback origin everywhere

Use either `http://localhost:9000` or `http://127.0.0.1:9000` consistently for
local gateway URLs. The gateway treats them as different origins.

Use the same hostname in these settings:

- The gateway URL in your MCP client configuration.
- `browserLogin.url` when using the local dev-login endpoint.
- The gateway callback and allowed-origin URLs registered with your identity
  provider, when testing with one.

Mixing hostnames can cause `oauth_callback_mismatch` during local login or a
callback URL error from your identity provider. The examples on this page use
`127.0.0.1`.

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

The local `/__zuplo/oauth/dev-login` endpoint signs you in as `dev-browser-user`
without an identity provider.

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

```jsonc
// config/policies.json
{
  "name": "dev-oauth",
  "policyType": "mcp-oauth-inbound",
  "handler": {
    "module": "$import(@zuplo/runtime/mcp-gateway)",
    "export": "McpOAuthInboundPolicy",
    "options": {
      // Required placeholders for local dev-login.
      "oidc": {
        "issuer": "http://127.0.0.1:9000",
        "jwksUrl": "http://127.0.0.1:9000/jwks",
      },
      "browserLogin": {
        "url": "http://127.0.0.1:9000/__zuplo/oauth/dev-login",
      },
    },
  },
}
```

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

The policy requires `oidc.issuer` and `oidc.jwksUrl`, but dev-login doesn't use
them. Keep the placeholder values in this example for local testing. The `/jwks`
URL is a placeholder, not an endpoint the gateway serves.

:::caution

Use an OIDC identity provider in deployed environments. The dev-login endpoint
only accepts requests from loopback origins and returns `403 Forbidden` for
other origins.

:::

### Configure local and deployed environments

Attach the same MCP OAuth policy to every MCP route. To use dev-login locally
and an identity provider when deployed, configure a generic `mcp-oauth-inbound`
policy with environment variables:

```jsonc
// config/policies.json
"options": {
  "oidc": {
    "issuer": "$env(OIDC_ISSUER)",
    "jwksUrl": "$env(OIDC_JWKS_URL)",
  },
  "browserLogin": {
    "url": "$env(BROWSER_LOGIN_URL)",
    "tokenUrl": "$env(BROWSER_LOGIN_TOKEN_URL)",
    "clientId": "$env(BROWSER_LOGIN_CLIENT_ID)",
    "clientSecret": "$env(BROWSER_LOGIN_CLIENT_SECRET)",
  },
},
```

For local development, set these values:

```bash title=".env"
OIDC_ISSUER=http://127.0.0.1:9000
OIDC_JWKS_URL=http://127.0.0.1:9000/jwks
BROWSER_LOGIN_URL=http://127.0.0.1:9000/__zuplo/oauth/dev-login
```

Leave `BROWSER_LOGIN_TOKEN_URL`, `BROWSER_LOGIN_CLIENT_ID`, and
`BROWSER_LOGIN_CLIENT_SECRET` unset locally. In each deployed environment, set
all six variables to your identity provider's values.

Provider-specific policies such as `mcp-auth0-oauth-inbound` use the provider's
login page and don't support dev-login. See
[Define one OAuth policy](./overview.mdx#2-define-one-oauth-policy).

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

Match your environment variable names to the `$env(...)` references in
`policies.json`.

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