next-zonesv0.1.0

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"] })],
  }),
});

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 }) };
  },
});

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…" })),

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:

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 }),

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

Edit this page on GitHub · Markdown