# Virtual MCP Servers

A **Virtual MCP server** is the endpoint AI clients connect to. It's not an
upstream MCP server itself — it's the gateway's curated front for one
[Origin MCP](./origin-mcp-servers.mdx). Each Virtual MCP has a slug, a public
URL, a chosen subset of the Origin's tools, and an optional list of teams
allowed to use it.

When a client like Claude Desktop, Cursor, ChatGPT, or Claude Code calls a
Virtual MCP, the gateway:

1. Authenticates the client against the project's OAuth identity provider.
2. Checks that the user's team membership allows access to this Virtual MCP.
3. Resolves the upstream credential (per-user OAuth, API key, or none) for the
   Origin behind this Virtual MCP.
4. Forwards the MCP JSON-RPC call to the upstream and filters the response based
   on the configured tool selection.

Each Virtual MCP routes to exactly one upstream MCP server. To expose two
upstreams, publish two Virtual MCPs. This 1:1 mapping keeps tool names
unambiguous, makes upstream credentials cleanly scoped per route, and is how the
gateway is built end to end — both in the Portal and in code config.

This page walks through creating and configuring a Virtual MCP in the portal,
then shows the equivalent route in `routes.oas.json` for projects configured in
code.

## Anatomy

Every Virtual MCP has four pieces of state:

- **Name** — what humans call it. Shown in the catalog and on the card in the
  Virtual MCP list.
- **Description** — a one-line summary of what the server exposes.
- **Slug** — the URL-safe identifier. The public URL is built from the slug as
  `${deploymentUrl}/v1/mcp/{slug}`.
- **Tools** — the subset of tools, drawn from one or more Origins, that this
  Virtual MCP exposes.

Teams can be assigned separately and govern who is allowed to authenticate to
the Virtual MCP. See [Teams](./teams.mdx) for the access-control model.

## The public URL

The URL a client connects to has the form:

```text
${deploymentUrl}/v1/mcp/{slug}
```

`deploymentUrl` is the project's deployment URL (the one shown in the project
overview), and `{slug}` is the Virtual MCP's slug. A slug like `linear-prod` in
a project deployed at `https://gateway-main-abc123.zuplosite.com` produces:

```text
https://gateway-main-abc123.zuplosite.com/v1/mcp/linear-prod
```

That's the URL clients paste into their MCP configuration. The gateway's catalog
page generates the snippet for each supported client (Claude Desktop, ChatGPT,
Cursor, VS Code) and copies it ready-to-use.

## Configure in the portal

The portal flow has three parts: create the Virtual MCP, pick tools, and assign
teams. Origins should already exist — see
[Origin MCP Servers](./origin-mcp-servers.mdx) if not.

### Step 1: Create the Virtual MCP

Open
[**MCP Virtual MCP**](https://portal.zuplo.com/+/account/project/mcp/virtual-mcp)
in the Zuplo Portal. When the project has no Virtual MCPs yet, the empty state
reads **"Let's add your first Virtual MCP"** with an **Add Virtual MCP** button.
Once at least one exists, the same button is in the top-right.

1. Click **Add Virtual MCP**.
2. In the **Create Virtual MCP** dialog, fill in:

   | Field           | Required | Notes                                                                                    |
   | --------------- | :------: | ---------------------------------------------------------------------------------------- |
   | **Name**        |   Yes    | The display name in the catalog and the Virtual MCP list.                                |
   | **Description** |    No    | Shown on the card and in client connection instructions.                                 |
   | **Slug**        |   Yes    | Lowercase letters and hyphens only. Auto-derived from the name; override it when needed. |

3. Click **Save**.

The new Virtual MCP appears as a card in the list. Initially the card has no
tools — that's the next step.

### Step 2: Select tools

On the Virtual MCP card, click **Select tools** (or the **N tools** chip if some
tools are already selected). The **Tools** dialog opens as a split view:

- **Left column — Origin MCPs.** Every Origin in the project is listed,
  collapsible. Each tool appears as a card with a toggle. A search box filters
  tools across all Origins. An **Enable all** button selects every tool from an
  Origin at once.
- **Right column — Virtual MCP.** The tools currently exposed by this Virtual
  MCP. Hover a card to reveal a **Remove** button. A **Disable all** button
  drops every tool from a single Origin.

Pick tools by toggling them on the left; they appear on the right. The picker
lets you browse every Origin, but a Virtual MCP binds to exactly one upstream
Origin. To expose two upstreams, publish two Virtual MCPs.

When the selection is complete, click **Save**. The tool count chip on the card
updates.

:::tip

If the left column shows **"No origins found. Please add an origin MCP to get
started."**, the project doesn't have any Origins yet. Add one from the
[Origin MCP](./origin-mcp-servers.mdx) page first.

:::

### Step 3: Assign teams

By default, members of all project teams can connect to a Virtual MCP. To
restrict access:

1. On the Virtual MCP card, click the **Teams** chip in the footer.
2. In the popover, check each team that should have access. Indented entries are
   sub-teams.
3. Click **Save**.

Only members of the checked teams can authenticate to this Virtual MCP. See
[Teams](./teams.mdx) for the full access-control model, including roles and the
team tree.

If the project has no teams yet, the popover shows a link to create one.

### Step 4: Connect a client

Once tools are selected and teams are assigned, the Virtual MCP is ready to
serve clients. From the
[**Catalog**](https://portal.zuplo.com/+/account/project/mcp/catalog), click the
Virtual MCP's **Configuring the MCP** chip to open the connection instructions
dialog. The dialog has tabs for Claude, ChatGPT, Cursor, and VS Code; each tab
shows a ready-to-paste config snippet with the Virtual MCP's URL filled in.
Cursor also shows a one-click install button.

The first connect for each user triggers the gateway's OAuth flow — the client
opens a browser, the user authenticates with the configured identity provider,
and the gateway issues a bearer token bound to the Virtual MCP's URL. Subsequent
calls reuse that token until it expires.

## Configure with code

Every Virtual MCP in the portal maps to a route in `routes.oas.json` plus a
small set of inbound policies in `policies.json`. Projects that want full policy
control — including [capability filtering](./capability-filtering.mdx),
per-route rate limits, or composition with other Zuplo policies — configure
routes directly.

The canonical layout — the same one Zuplo's own MCP Gateway dogfood project uses
— pairs one route per upstream MCP server with two policies: an OAuth policy
that authenticates inbound clients, and a token-exchange policy that resolves
the upstream credential.

### Route shape

A Virtual MCP route is a multi-method operation (`get,post`) that uses
`McpProxyHandler` as its handler and lists the inbound policies that handle
OAuth and upstream credentials.

```jsonc
// config/routes.oas.json
"/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": [
          "auth0-managed-oauth",
          "mcp-token-exchange-linear"
        ]
      }
    }
  }
}
```

Required elements:

- **Path** — `/mcp/{slug}` matches the portal pattern. The path can be anything,
  but `/mcp/{slug}` keeps the URL consistent with portal-managed Virtual MCPs.
- **`operationId`** — required for every MCP route. The operationId is the
  stable identity used for OAuth audience binding, upstream auth state, and
  analytics. Two MCP routes can't share an `operationId`.
- **`get,post`** — Zuplo's multi-method shorthand. `McpProxyHandler` rejects
  `GET` with HTTP 405 (no SSE streams) and forwards `POST` to the upstream.
- **`handler`** — `McpProxyHandler` exported from `@zuplo/runtime/mcp-gateway`.
- **`handler.options.rewritePattern`** — the upstream MCP server URL. The string
  must be static or use a single `$env(...)` reference; dynamic patterns derived
  from the request are rejected.
- **`policies.inbound`** — the OAuth policy (required for any authenticated MCP
  route) and, when the upstream needs credentials, the token exchange or header
  policy that resolves them.

### Required policies

Two policies almost every MCP route uses:

```jsonc
// config/policies.json
{
  "name": "auth0-managed-oauth",
  "policyType": "mcp-auth0-oauth-inbound",
  "handler": {
    "module": "$import(@zuplo/runtime/mcp-gateway)",
    "export": "McpAuth0OAuthInboundPolicy",
    "options": {
      "auth0Domain": "$env(AUTH0_DOMAIN)",
      "clientId": "$env(AUTH0_CLIENT_ID)",
      "clientSecret": "$env(AUTH0_CLIENT_SECRET)"
    }
  }
},
{
  "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" }
    }
  }
}
```

- **`mcp-auth0-oauth-inbound`** (or the generic `mcp-oauth-inbound` for
  non-Auth0 providers) authenticates the inbound client request and turns the
  gateway into an OAuth 2.1 Resource Server. Only one such policy can exist per
  project, shared across every MCP route.
- **`mcp-token-exchange-inbound`** resolves the upstream credential for this
  route. Each MCP route can have one of these; the policy reference covers the
  full options surface.

When the upstream uses an API key instead of OAuth, replace
`mcp-token-exchange-*` with
[`set-upstream-api-key-inbound`](../policies/set-upstream-api-key-inbound.mdx)
or [`set-headers-inbound`](../policies/set-headers-inbound.mdx). See
[Origin MCP Servers](./origin-mcp-servers.mdx#pattern-1-api-key-in-a-header) for
a worked example.

### Plugin registration

Code-configured projects need to load the MCP Gateway plugin once in
`modules/zuplo.runtime.ts`:

```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, callback, and upstream connection endpoints the
gateway needs. It's a no-op if the project doesn't have any MCP-related
policies, so it's safe to add even in projects that aren't yet using MCP.

### Compatibility date

MCP Gateway routes require a `compatibilityDate` of **`2026-03-01`** or later in
`zuplo.jsonc`:

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

The `mcp-token-exchange-inbound` policy uses chained response hooks for the
upstream 401 retry semantics, which landed on that compatibility date. Older
dates may silently drop the retry hook, leading to confusing intermittent
failures the first time an upstream token needs refreshing.

### How portal Virtual MCPs map to routes

Each Virtual MCP managed in the portal corresponds to a route the runtime
generates for you. The mapping is direct:

| Portal field       | Route equivalent                                              |
| ------------------ | ------------------------------------------------------------- |
| Slug               | The path segment in `/mcp/{slug}`                             |
| Name + Description | OpenAPI `summary` and `description`                           |
| Selected tools     | A capability filter applied to the upstream `tools/list`      |
| Assigned teams     | Portal-side metadata; enforcement is a near-term roadmap item |
| Origin URL         | `McpProxyHandler.options.rewritePattern`                      |

The runtime generates the underlying route from the portal configuration so that
the same Virtual MCP behaves identically whether it was created in the portal or
written by hand. The portal abstraction is convenience over the same
`routes.oas.json` + `policies.json` foundation.

## One Virtual MCP, one Origin

A Virtual MCP routes to exactly one Origin. The Portal's tool picker shows every
tool from every Origin in the project so you can browse, but the saved Virtual
MCP binds to a single upstream and exposes a curated subset of that upstream's
tools.

To expose two upstreams, publish two Virtual MCPs — one per upstream. The
[multi-upstream pattern](./code-config/multi-upstream.mdx) walks through the
worked example with Linear and Stripe.

The 1:1 mapping is intentional: composite servers (one Virtual MCP fronting
tools from multiple unrelated upstreams) make tool names ambiguous, scatter
upstream credentials across one route, and create a maintenance burden for a use
case that's better served by two clear routes. The Portal UI for multi-Origin
selection is evolving; configure one Origin per Virtual MCP today.

## Reference

- [Origin MCP Servers](./origin-mcp-servers.mdx) — the upstream servers a
  Virtual MCP draws tools from.
- [Teams](./teams.mdx) — group members and restrict which Virtual MCPs each team
  can connect to.
- [Capability Filtering](./capability-filtering.mdx) — narrow the tools and
  resources a route exposes downstream.
- Policy reference:
  - `mcp-auth0-oauth-inbound`
  - `mcp-oauth-inbound`
  - `mcp-token-exchange-inbound`
  - `mcp-capability-filter-inbound`
- For a full working layout, see the route and policy patterns demonstrated
  throughout this page — one route per upstream, one OAuth policy shared across
  all MCP routes, and one token-exchange or header policy per route.
