# MCP Gateway quickstart (Local Dev)

<QuickstartPicker mode="local" alternateLink="/mcp-gateway/quickstart" />

Build a Zuplo MCP Gateway fronting Linear, running locally at
`http://127.0.0.1:9000/mcp/linear-v1`. By the end, Claude Desktop connects over
the gateway's per-user OAuth flow and answers "list my open Linear issues" with
real results.

Any Zuplo project becomes a gateway by adding a plugin, a couple of policies,
and a route. This guide uses Linear as the upstream and the built-in
**dev-login** shortcut for sign-in, so you skip identity-provider setup to try
it out. For production, swap in your provider: the gateway wraps Auth0, Okta,
Microsoft Entra, Google, Clerk, Cognito, Keycloak, Logto, OneLogin, PingOne, and
WorkOS, plus a generic OIDC fallback. See the
[provider catalog](./auth/overview.mdx#identity-providers).

Prefer the browser with no local setup? The
[Portal quickstart](./quickstart.mdx) reaches the same result through the Zuplo
Portal UI.

## Prerequisites

- [Node.js](https://nodejs.org/en/download) 20 or higher.
- A local Zuplo project. Create an empty one with:

  ```bash
  npx create-zuplo-api@latest --empty
  ```

  Then `cd` into the new directory. See
  [`create-zuplo-api`](../cli/create-zuplo-api.mdx) for other options, or
  [import an existing portal project](../articles/local-development.mdx#import-your-existing-project)
  by connecting it to Git and cloning it.

:::note

New projects created with `create-zuplo-api` ship a recent `compatibilityDate`,
so MCP Gateway features work out of the box. If you're adding the gateway to an
older project and the build complains about the compatibility date, see
[Compatibility dates](./code-config/compatibility-dates.mdx).

:::

<Stepper>

1. **Register the MCP Gateway plugin**

   Open `modules/zuplo.runtime.ts` (create it if it doesn't exist) and register
   `McpGatewayPlugin`:

   ```ts title="modules/zuplo.runtime.ts"
   import { RuntimeExtensions } from "@zuplo/runtime";
   import { McpGatewayPlugin } from "@zuplo/runtime/mcp-gateway";

   export function runtimeInit(runtime: RuntimeExtensions) {
     runtime.addPlugin(new McpGatewayPlugin());
   }
   ```

   The plugin registers the OAuth metadata, authorization endpoints, consent
   page, and upstream connect callbacks the gateway needs.

2. **Add an OAuth policy with the dev-login shortcut**

   Setting up a real identity provider for local development is friction. You'd
   register a loopback 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`.

   Open `config/policies.json` and add the generic OAuth policy pointed at the
   dev-login URL:

   ```json title="config/policies.json"
   {
     "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"
         }
       }
     }
   }
   ```

   :::caution

   `/oauth/dev-login` returns `403 Forbidden` for any request that doesn't
   arrive over loopback, so it's safe to leave configured, but only useful in
   local dev. Production deployments should use a real OIDC provider through one
   of the [IdP wrappers](./auth/overview.mdx#identity-providers). A common
   pattern is keeping two OAuth policies (one for production, one for dev) and
   selecting between them in `routes.oas.json` by environment.

   :::

   When you do switch to a real provider, its policy reads credentials from
   `$env(...)` references. Define those values in a `.env` file at the project
   root:

   ```bash title=".env"
   MCP_AUTH0_DOMAIN=your-tenant.us.auth0.com
   MCP_AUTH0_CLIENT_ID=your-auth0-web-app-client-id
   MCP_AUTH0_CLIENT_SECRET=your-auth0-web-app-client-secret
   ```

   `.env` is read when `npm run dev` starts, so restart the dev server after
   adding or changing a variable. Never commit `.env`. Check in a `.env.example`
   with placeholder values instead. The dev-login shortcut above needs no
   environment variables, so you can skip this until you wire up a provider.

3. **Add a token-exchange policy for the upstream**

   Each OAuth-protected upstream gets its own `mcp-token-exchange-inbound`
   policy. It looks up the user's upstream credential and attaches it as the
   upstream `Authorization` header. Add this entry to `config/policies.json`:

   ```json title="config/policies.json"
   {
     "name": "mcp-token-exchange-linear",
     "policyType": "mcp-token-exchange-inbound",
     "handler": {
       "module": "$import(@zuplo/runtime/mcp-gateway)",
       "export": "McpTokenExchangeInboundPolicy",
       "options": {
         "displayName": "Linear",
         "protectedResourceMetadataUrl": "https://mcp.linear.app/.well-known/oauth-protected-resource",
         "authMode": "user-oauth",
         "scopes": [],
         "clientRegistration": { "mode": "auto" }
       }
     }
   }
   ```

   `authMode: "user-oauth"` means each user connects their own Linear account
   the first time they call the route. `clientRegistration: { "mode": "auto" }`
   lets the gateway register itself with Linear's OAuth server on demand, so no
   upstream client credentials in source control.

4. **Add the route**

   Open `config/routes.oas.json` and add an MCP route. The handler points at
   Linear's MCP server URL; the inbound policy chain runs the OAuth policy
   followed by the token-exchange policy:

   ```json title="config/routes.oas.json"
   {
     "openapi": "3.1.0",
     "info": { "title": "MCP Gateway", "version": "0.1.0" },
     "paths": {
       "/mcp/linear-v1": {
         "get,post": {
           "operationId": "linear-mcp-server",
           "summary": "Linear MCP Proxy",
           "x-zuplo-route": {
             "corsPolicy": "none",
             "handler": {
               "module": "$import(@zuplo/runtime/mcp-gateway)",
               "export": "McpProxyHandler",
               "options": {
                 "rewritePattern": "https://mcp.linear.app/mcp"
               }
             },
             "policies": {
               "inbound": ["dev-oauth", "mcp-token-exchange-linear"]
             }
           }
         }
       }
     }
   }
   ```

   `operationId` is the stable identifier for the route. It appears in analytics
   and is part of the per-user upstream connection key, so pick it once and
   don't change it. The path is whatever you set; `/mcp/<provider>-v<n>` is the
   convention.

5. **Run the gateway**

   From the project root:

   ```bash
   npm run dev
   ```

   The route is now reachable at `http://127.0.0.1:9000/mcp/linear-v1`.

   :::tip{title="Checkpoint: confirm the OAuth policy is wired up"}

   Send an unauthenticated POST and expect a `401`:

   ```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}'
   ```

   The response should be `401 Unauthorized` with a `WWW-Authenticate: Bearer`
   header pointing at `/.well-known/oauth-protected-resource/mcp/linear-v1`.
   That 401 confirms the OAuth policy is loaded. If you see a 200, 404, or 500
   instead, the OAuth policy isn't attached to the route.

   :::

   :::caution{title="Use 127.0.0.1, not localhost"}

   OAuth metadata and callback URLs key off the request origin. Other loopback
   aliases (`localhost`, `::1`) can break OAuth subtly in local dev. See
   [Local development](./code-config/local-development.mdx) for the full set of
   local-only details, including the known `workerd` restart quirk.

   :::

6. **Connect Claude Desktop**

   Open Claude Desktop, go to **Settings → Connectors**, scroll to the bottom,
   and click **Add custom connector**. Paste
   `http://127.0.0.1:9000/mcp/linear-v1` and click **Add**.

   Claude Desktop opens the gateway's OAuth flow in a browser:
   1. The dev-login shortcut signs you in without any IdP prompt.
   2. The gateway's consent page lists Linear with a **Connect** button.
   3. Click **Connect**, complete Linear's OAuth flow, then click **Authorize**
      to finish.

   :::tip{title="Checkpoint: Claude is connected"}

   Back in Claude Desktop, the new connector appears in **Settings →
   Connectors** marked as connected. Subsequent requests reuse the tokens the
   gateway just issued.

   :::

   For per-client setup details, see
   [Connect MCP clients](./connect-clients/overview.mdx).

7. **Test it**

   In Claude Desktop, prompt the model with something that requires Linear.
   "list my open issues" works well. Claude asks for permission to call the
   tool, then returns results proxied through the gateway.

</Stepper>

You now have a working MCP Gateway in front of Linear, running locally: Claude
Desktop signs in through the dev-login shortcut, the gateway exchanges that for
a per-user Linear token, and every call is proxied through. The same shape (one
OAuth policy, one token-exchange policy per upstream, one route per upstream)
scales out to as many upstream MCP servers as you want to front.

:::caution{title="Deploy to production before sharing"}

The local gateway on `127.0.0.1` is for development only, and the dev-login
shortcut works over loopback alone. Before giving others access, swap in a real
identity provider and ship the gateway through the Zuplo Portal. See
[environments](../articles/environments.mdx) for setting up a production
deployment.

:::

## Next steps

- [Deploy from the Portal](./quickstart.mdx): swap the dev-login shortcut for a
  real identity provider and ship the gateway through the Zuplo Portal.
- [Local development](./code-config/local-development.mdx): the dev-login
  shortcut in depth, environment variables, and local-only quirks.
- [Connect more clients](./connect-clients/overview.mdx): Claude Code, Cursor,
  VS Code, ChatGPT, and any other MCP client.
- [How it works](./how-it-works.mdx): the request lifecycle and the two OAuth
  surfaces.
- [Add more upstreams](./code-config/multi-upstream.mdx): front several upstream
  MCP servers from one Zuplo project.
- [Capability filtering](./capability-filtering.mdx): curate the tools, prompts,
  and resources each route exposes.
