Zones as an MCP server
Zones can serve the Model Context Protocol, so an assistant (Claude, an IDE, an
agent) works with the running Zones: reads what serves, installs and rolls back zone versions, reads the metrics, and
calls tools of your own. The shell declares it in its zoneConfig:
// shell/next.config.mjs
import { zoneConfig } from "@runsnip/next-zones/config";
import { Mcp, Tools, Tool, LivePull, Metrics, Skills, Bearer, OAuth } from "@runsnip/next-zones/mcp";
import { searchOrders } from "./mcp/orders.mjs"; // a tool of your own, from a module of the shell
export default zoneConfig({
mount: "/",
metrics: true,
mcp: Mcp({
tools: Tools(LivePull(), Metrics(), searchOrders),
skills: Skills("./mcp/skills/release"), // optional
auth: [Bearer({ env: "MCP_TOKEN" }), OAuth({ issuer: "https://auth.example.com", scopes: ["zones:read"] })],
}),
});
- Its own path,
/_next-zones/mcp(underendpoints.basewhen the shell sets one;Mcp({ path })chooses another). It answers whatever endpoints are declared: with every endpoint off, the MCP server still serves, and its tools still install and pull, under the same rules. - Zones only (mode
"zones",output: "standalone"included). One app (mode"single") has no Zones, and no MCP server. - Light. The transport and the JSON-RPC are next-zones' own; OAuth tokens are checked with
@runsnip/jwks, which has no dependency of its own (see the numbers). It is an optional peer dependency: install it (npm install @runsnip/jwks) only to useOAuth(…), or give another implementation asMcp({ jwks })(below).
Tools#
Tools(…) takes any number of tools, or lists of them. None is required: Zones' own are there to be picked, beside
yours.
| Tools | What they do | |
|---|---|---|
LivePull() |
zones_status |
Each zone's active version, the zones that failed at boot, uptime and memory |
zones_images |
The zone images in the store, by zone (one zone with zone) |
|
zones_pull |
Pulls { zone, version } into the store from Zones' sources, without installing it |
|
zones_install |
Installs, swaps to or rolls back to { zone, version }, with no restart; a version the store lacks is pulled first |
|
Metrics() |
zones_metrics |
The metrics as Prometheus text, filtered by match (a name prefix, or /regex/); needs the shell's metrics: true |
A pull on request (zones_pull, and zones_install of a version the store lacks) needs the zone's livePull: true,
as for the admin URLs (live pulls); a version already in the store installs either way.
LivePull({ tools: ["status", "images"] }) keeps only some of them; LivePull({ scopes: ["zones:release"] }) makes
pulling and installing need that OAuth scope.
Your own#
// shell/mcp/orders.mjs
import { Tool } from "@runsnip/next-zones/mcp";
import { db } from "../lib/db.mjs";
export const searchOrders = Tool({
name: "orders_search",
description: "Finds orders by customer email.",
input: { type: "object", properties: { email: { type: "string" }, limit: { type: "integer", minimum: 1, maximum: 50 } }, required: ["email"] },
annotations: { readOnlyHint: true },
scopes: ["orders:read"], // OAuth scopes a caller needs
zones: [], // what it may do with Zones (below): nothing
timeoutMs: 10_000,
async handler({ email, limit = 10 }, { auth, signal }) {
return { orders: await db.orders.find({ email, limit, signal }) };
},
});
inputis the arguments' JSON Schema: a call whose arguments do not fit is refused before the handler runs, with each problem named.outputdeclares the structured result's.- The handler returns a string (text), an object (structured content, and its JSON as text), or an MCP result
(
{ content, structuredContent }). A thrown error is the call's error (isError), which the assistant reads. context:auth, the caller (below);signal, aborted when the client leaves or the call passestimeoutMs(30 s by default);zones, what the tool may do with Zones.zones: a tool reaches Zones only as far as it declares."read"givesstatus(),images()andmetrics(match);"pull","install"and"prune"give those. None by default.- Results over 8 MiB are refused.
A tool is any Tool(…): write your own factories (Orders(), Billing()) returning one or a list, and pass them to
Tools(…) beside Zones' own.
A tool runs in Zones' process, with the web. Authorization decides who may call it, not what its code may do: a tool's code can read the process's environment and files, and a slow or broken one takes the web's time and memory. Keep code you do not trust (a third party's) out of the process: run it as a service of its own, and give Zones a tool that calls it.
Skills#
Skills(…) serves skills to the assistant, each as MCP resources (skill://<name>/<file>, every file of its folder)
and as a prompt (its SKILL.md):
import { Skills, Skill, NextZonesSkill } from "@runsnip/next-zones/mcp";
skills: Skills("./mcp/skills/release", NextZonesSkill(), Skill({ name: "oncall", description: "…", content: "# On call…" })),
- a folder holding a
SKILL.md(its front matter'snameanddescription), from the shell's folder or as afile:URL; NextZonesSkill(): next-zones' own skill, so the assistant knows how zones work;Skill({ name, description, content, files }), written out.
Leave skills out when there are none.
Authorization#
auth takes any number of methods; a request passes with one of them. Without auth, the admin rule holds: the admin
token (NEXT_ZONES_ADMIN_TOKEN) as a bearer token, or, when none is set, a request from the same machine.
| Method | What passes |
|---|---|
Bearer({ token }), Bearer({ tokens }) |
Static tokens (16 characters or more), compared in constant time |
Bearer({ env: "MCP_TOKEN" }) |
The token from the environment, read when Zones starts: it stays out of the config. Zones refuses to start when it is unset |
Bearer({ verify }) |
verify(token, { headers }) decides: what it returns ({ subject, scopes }) is the caller |
OAuth({ issuer, audience, scopes }) |
OAuth 2.1 access tokens, as the MCP authorization spec has it (below) |
OAuth. Zones is a resource server:
- it publishes its protected resource metadata (RFC 9728) at
/.well-known/oauth-protected-resource<path>, naming the issuer; - a request without a valid token gets 401, with
WWW-Authenticate: Bearer resource_metadata="…", which a client follows to the authorization server; a token without the scopes needed gets 403,error="insufficient_scope"; - a JWT is checked with
@runsnip/jwksagainst the issuer's keys (its JWKS, found from its metadata orjwksUri, kept, fetched again for a key rotated in): signature (RS*,PS*,ES*,EdDSA; a key verifies only its own type's),iss,aud,expandnbf(60 s of leeway), scopes (scopeorscp). Withintrospection: { url, clientId, clientSecret }, any token the issuer confirms active (RFC 7662); audmust holdaudience, by default the server's canonical URL. Behind a proxy, give it:Mcp({ url: "https://app.example.com/_next-zones/mcp" }).
A tool's scopes (and LivePull({ scopes })) apply to OAuth callers, and to a Bearer({ verify }) that returns
scopes; a static token and the admin rule have every scope.
Another token checker#
OAuth(…) checks tokens with @runsnip/jwks by default. Mcp({ jwks }) gives another implementation of its contract:
an object with createAccessTokenVerifier(options) (and introspectAccessToken(token, options) for introspection).
import * as myJwks from "./auth/jwks.mjs";
mcp: Mcp({ tools, auth: OAuth({ issuer: "https://auth.example.com" }), jwks: myJwks }),
createAccessTokenVerifier({ issuer, audience, scopes, clockToleranceS, jwksUri, algorithms })returns(token) => Promise<{ subject, scopes, payload }>;introspectAccessToken(token, { endpoint, clientId, clientSecret, issuer, audience, scopes })returns the same.- A refusal throws an error with
@runsnip/jwks' codes:ERR_JWT_CLAIMwithclaim: "scope"is answered 403 (insufficient_scope);ERR_JWKS_FETCH,ERR_DISCOVERYandERR_INTROSPECTION500; any other, 401.
From a browser, a request whose Origin is not the server's own is refused (403), against DNS rebinding;
Mcp({ origins }) allows others.
The protocol#
Streamable HTTP, stateless: a POST carries a JSON-RPC message (a batch from a 2025-03-26 client) and gets its answer
as JSON; a notification gets 202. There is no server stream (GET is 405). Versions 2025-11-25, 2025-06-18 and
2025-03-26. Methods: initialize, ping, tools/list, tools/call, and with skills resources/list,
resources/read, resources/templates/list, prompts/list, prompts/get.
A client connects with the URL, for example with Claude Code:
claude mcp add --transport http zones https://app.example.com/_next-zones/mcp --header "Authorization: Bearer $MCP_TOKEN"
With metrics on, each call is counted: nextzones_mcp_calls_total{tool, outcome}.
Deploying#
next-zones startandserveread the MCP server from the shell'snext.config.output: "standalone": the declaration holds code (your tools' handlers), which no record keeps, sonext-zones buildcopies the shell'snext.configinto the standalone folder with every module it imports (traced with Next's own@vercel/nft), and the skill folders;zones.jsreads the MCP server from there.createZones({ mcp })gives or replaces it from code;mcp: falseturns it off.
next-zones doctor checks the declaration: Metrics() without metrics: true, a path a route or a zone serves,
OAuth(…) without @runsnip/jwks installed (nor Mcp({ jwks })), a Bearer({ env }) whose variable is not set, mode
"single".
The numbers#
Node 24.16, Apple M1, measured 2026-10-10. The reference is the official SDK (@modelcontextprotocol/sdk 1.32.1, its
McpServer and stateless StreamableHTTPServerTransport), serving the same one-argument tool; 3000 sequential
tools/call over a kept-alive connection after 300, two rounds:
| next-zones | the SDK | |
|---|---|---|
| Installed | 52 KB of source, and @runsnip/jwks (13 KB packed, no dependency) |
26 MB, 91 packages |
| Loading it (median of 5 fresh processes) | 3.5 ms, +3.1 MB RSS | 78 ms, +37 MB RSS |
| A call, p50 | 0.078, 0.085 ms | 0.193, 0.216 ms |
| A call, p99 | 0.17, 0.34 ms | 1.15, 1.08 ms |