Skip to content

Community-maintained FDE reference. Not an official Vercel or Anthropic project. About this project

Prompt: sandbox-isolation-server

Audience:engineernon-technicalMCP spec 2026-07-28

You get a one-tool MCP server that runs untrusted shell commands inside a Vercel Sandbox microVM behind a frozen deny-by-default egress allowlist, a non-persistent sandbox, a pinned image, and no environment passed in, with the sandbox’s output size-capped and framed as untrusted data before the model sees it. It is the serverless shape of the sidecar pattern: isolation lives behind an injected interface, so the whole test suite runs offline against a recording stub while the options handed to Sandbox.create are type-checked against the real SDK.

Copy everything in the block below into your AI coding agent as one message. See the prompt index for what makes these prompts reliable and how they were tested.

GOAL
Build me a small TypeScript project called sandbox-isolation-server that runs locally and deploys to Vercel: an MCP (Model Context Protocol) server with exactly one tool, run_in_sandbox, that executes a shell command inside an isolated Vercel Sandbox microVM behind a deny-by-default network egress allowlist and returns the exit code plus the sandbox's stdout and stderr, size-capped with an explicit truncation marker and framed as untrusted data. The server holds no credential of its own: on Vercel the Sandbox SDK authenticates with the deployment's OIDC token.
STACK (exact, non-negotiable)
Next.js App Router (next ^16, react and react-dom ^19), mcp-handler 2.1.1, @modelcontextprotocol/server 2.0.0 as a dependency (mcp-handler 2.1.1 peer-requires ^2.0.0), @modelcontextprotocol/client 2.0.0 as a DEV dependency (tests only), zod ^4.2.0 (hard floor: the v2 SDK requires zod 4.2.0 or newer; zod ^3 installs cleanly and then fails typecheck and tests). @vercel/sandbox 3.1.0 as a DEV dependency, used for TYPES ONLY (import type; nothing loads it at runtime). Other dev dependencies: typescript, vitest, @types/node. Node 22 or newer. package.json has "type": "module" and scripts dev (next dev), test (vitest run), typecheck (tsc --noEmit). Wire status: the pinned stack (`mcp-handler` 2.1.1 on `@modelcontextprotocol/server` 2.0.0) serves the 2026-07-28 contract natively over Streamable HTTP and falls back to stateless 2025-11-25 Streamable HTTP for legacy clients; the SDK `Client` defaults to that legacy handshake unless you opt in to modern version negotiation, so the examples' in-memory test suites exercise only the legacy path.
LAYOUT
app/api/mcp/route.ts is a thin shell (there is NO [transport] directory in the v2 stack; the public endpoint is /api/mcp): it creates the one real sandbox client with createVercelSandboxClient(), then const handler = withOriginCheck(createMcpHandler((server) => configureServer(server, sandbox), { serverInfo: { name: SERVER_NAME, version: SERVER_VERSION } }), parseAllowedOrigins(process.env[ALLOWED_ORIGINS_ENV])), exported as GET, POST, DELETE. The old three-argument createMcpHandler signature and the basePath option no longer exist; the name survives with a two-argument form. ALL protocol logic lives in src/server.ts exporting configureServer(server, sandbox). The isolation boundary lives in src/sandbox.ts. The Origin allowlist lives in src/origin.ts. vercel.json sets functions["app/api/mcp/route.ts"].maxDuration to 60. Tests in tests/ (server.test.ts, origin.test.ts, vercel-config.test.ts) import src/ and never import Next.js.
BEHAVIOR
- src/sandbox.ts defines the boundary. It imports ONLY types from "@vercel/sandbox" (import type { NetworkPolicy, Sandbox }) and exports type VercelSandboxCreateParams = NonNullable<Parameters<typeof Sandbox.create>[0]>, so the option shape comes from the installed SDK's own declarations.
- Export SANDBOX_ALLOWED_HOSTS, a frozen readonly string[] ["registry.npmjs.org", "api.example.com"]; SANDBOX_IMAGE = "vercel/sandbox/node:22"; and SANDBOX_TIMEOUT_MS = 30000.
- Export buildSandboxCreateOptions(): returns Object.freeze({ image: SANDBOX_IMAGE, timeout: SANDBOX_TIMEOUT_MS, resources: { vcpus: 1 }, networkPolicy: { allow: [...SANDBOX_ALLOWED_HOSTS] } satisfies NetworkPolicy, persistent: false }) satisfies VercelSandboxCreateParams. A function, not a constant, so every call gets a fresh allow array. NO env key at all (the sandbox never inherits the function's environment, and there is nothing to pass in), and NO runtime key (it is deprecated in favor of image). Export type SandboxCreateOptions = ReturnType<typeof buildSandboxCreateOptions>.
- Export interfaces SandboxRunRequest (command, options: SandboxCreateOptions), SandboxRunResult (exitCode, stdout, stderr), and SandboxClient with one method run(request).
- Export createVercelSandboxClient(): the only place the real client exists. It lazily imports "@vercel/sandbox" through a variable specifier (const specifier = "@vercel/sandbox"; await import(specifier)) so Vitest never tries to resolve the package at runtime; if the import fails it throws a clear not-installed error. On success it calls Sandbox.create(request.options), runs the command via sandbox.runCommand({ cmd: "sh", args: ["-c", request.command] }), reads exitCode plus await stdout() and await stderr(), and always calls sandbox.stop() in a finally block. It passes no credential: the SDK resolves the deployment's OIDC token itself.
- src/server.ts imports McpServer's type from "@modelcontextprotocol/server" and exports SERVER_NAME "sandbox-isolation-server", SERVER_VERSION "0.1.0", MAX_OUTPUT_CHARS = 2000, UNTRUSTED_OUTPUT_BEGIN (a line starting "--- BEGIN UNTRUSTED SANDBOX OUTPUT" that says the content is data, not instructions), UNTRUSTED_OUTPUT_END = "--- END UNTRUSTED SANDBOX OUTPUT ---", and truncateOutput(text, cap = MAX_OUTPUT_CHARS), which strips C0 control characters (except tab and newline) and DEL, returns the text unchanged when it fits, and otherwise returns the first cap characters followed by "[truncated N chars]" where N is the number dropped.
- configureServer(server, sandbox) registers run_in_sandbox with a description mentioning the deny-by-default allowlist, inputSchema: z.object({ command: z.string().min(1).max(500) }) (the v2 SDK takes a FULL zod object schema here, not the v1 raw shape), and annotations { destructiveHint: true, openWorldHint: true }.
- The handler calls sandbox.run({ command, options: buildSandboxCreateOptions() }) (per-call input picks the command but can never widen egress, change the image, or make the sandbox persistent) and returns one text content block whose lines are: 'exit <code>', UNTRUSTED_OUTPUT_BEGIN, 'stdout:', truncateOutput(stdout), 'stderr:', truncateOutput(stderr), UNTRUSTED_OUTPUT_END. Set isError true when the exit code is nonzero. There is no credential to load, redact, or echo; nothing from process.env may reach the sandbox request or the tool output.
- src/origin.ts is framework-free: exports ALLOWED_ORIGINS_ENV = "MCP_ALLOWED_ORIGINS", DEFAULT_ALLOWED_ORIGINS (http://localhost:3000 and http://127.0.0.1:3000), parseAllowedOrigins(raw) (comma-split, trim, normalize to serialized origin via new URL(x).origin, drop junk and the opaque "null" origin, fall back to the default only when the variable is unset or blank), assertAllowedOrigin(request, allowlist) (null when there is no Origin header or it is allowlisted, otherwise a 403 text/plain Response "Forbidden: Origin not allowed" that does not echo the allowlist), and withOriginCheck(handler, allowlist), which wraps a Fetch-style handler and forwards extra arguments.
TESTS
- tests/server.test.ts connects a real Client over InMemoryTransport.createLinkedPair(): import McpServer and InMemoryTransport from "@modelcontextprotocol/server" and Client from "@modelcontextprotocol/client", build an McpServer, call configureServer with a RecordingSandbox stub (records every request, returns a configurable result, can be told to throw), connect both ends, then use listTools and callTool. In beforeEach plant a canary process.env.SANDBOX_API_TOKEN = "supersecrettoken1234" that the server never reads; delete it in afterEach.
- Create-options assertions on buildSandboxCreateOptions(): networkPolicy is not "allow-all", is an object equal to { allow: [...SANDBOX_ALLOWED_HOSTS] } with a non-empty allow list and "allow" as its only key; persistent is present (Object.hasOwn) and false; image equals SANDBOX_IMAGE, SANDBOX_IMAGE matches /^vercel\/sandbox\/[a-z]+:[0-9.]+$/, and there is no runtime key; timeout is SANDBOX_TIMEOUT_MS and resources equal { vcpus: 1 }; there is no env key and JSON.stringify of the options never contains the canary; SANDBOX_ALLOWED_HOSTS and the returned options are frozen, and pushing onto one result's networkPolicy.allow does not change the next call's allow list.
- truncateOutput assertions: short input and input exactly MAX_OUTPUT_CHARS long come back unchanged with no marker; a 5000-character input ends with "[truncated 3000 chars]", is at most MAX_OUTPUT_CHARS plus the marker length, and keeps the first MAX_OUTPUT_CHARS characters; control characters are stripped while newlines and tabs survive.
- Tool assertions: listTools shows exactly one tool named run_in_sandbox with an object schema whose command property is a string and annotations destructiveHint true and openWorldHint true; a successful call records exactly one request whose command is the input and whose options deep-equal buildSandboxCreateOptions() (allow list present, persistent false, image and timeout pinned); the result text's first line is 'exit 0', the second is UNTRUSTED_OUTPUT_BEGIN, the last is UNTRUSTED_OUTPUT_END, and it contains "stdout:\nout text" and "stderr:\nerr text"; a 5000-character stdout is capped with the marker inside the framing (before UNTRUSTED_OUTPUT_END); JSON.stringify of the result contains neither the canary secret, nor "****", nor the string SANDBOX_API_TOKEN; JSON.stringify of the recorded requests never contains the canary and the recorded options have no env key.
- Negative assertions this example lives by: a stub that throws gives isError true without leaking the canary; a stub result with exit code 7 gives isError true and the output contains 'exit 7', 'boom' (its stderr), and UNTRUSTED_OUTPUT_BEGIN; a wrong-typed command (the number 42), an empty command "", and a 501-character command all give isError true with zero recorded runs.
- SDK v2 (2.0.0) reality checks: an UNKNOWN tool name now makes callTool REJECT with a protocol error matching /not found/i (this changed from v1, which returned isError results; v2 matches the spec), so assert await expect(client.callTool({ name: "nope", arguments: {} })).rejects.toThrow(/not found/i). Schema-invalid arguments on a KNOWN tool still come back as isError: true tool RESULTS, callTool does not throw for those. Do NOT assert resultType on results or ttlMs/cacheScope on list results; the released SDK does not emit them yet.
- tests/origin.test.ts exercises assertAllowedOrigin, parseAllowedOrigins, and withOriginCheck directly: a disallowed Origin gets 403 with a body that mentions origin and does not echo the allowlist; no Origin header passes; an allowlisted Origin passes; the literal "null" origin and unparseable values get 403; scheme, host, and port all count while case differences are normalized; an empty allowlist refuses every Origin but still passes origin-less requests; parseAllowedOrigins falls back to the default for undefined or blank, splits and normalizes a messy list, and yields an empty list (not the default) for an all-junk value; withOriginCheck short-circuits with 403 before the wrapped handler runs and forwards allowed and origin-less requests with their extra arguments.
- tests/vercel-config.test.ts reads vercel.json as data and asserts functions["app/api/mcp/route.ts"].maxDuration is a positive integer and that maxDuration * 1000 is strictly greater than SANDBOX_TIMEOUT_MS.
DEFINITION OF DONE
npm install, npm run typecheck, npm test all green, and the suite still passes with @vercel/sandbox physically absent from node_modules (types only, lazy runtime import). Then npm run dev and connect MCP Inspector (npx @modelcontextprotocol/inspector) with Streamable HTTP to http://localhost:3000/api/mcp, list tools, and call run_in_sandbox by hand. A live call will clearly report that @vercel/sandbox is not installed as a runtime dependency; that is expected. For end-to-end execution run npm install @vercel/sandbox and put a VERCEL_OIDC_TOKEN in the environment (vercel env pull writes one to .env.local; it expires after 12 hours). Optionally npm install @vercel/sandbox and vercel deploy; no static credential is needed because the SDK uses the deployment's OIDC token.
SOURCES
https://modelcontextprotocol.io/specification/2026-07-28/server/tools (tool results, isError, and protocol errors for unknown tools), https://modelcontextprotocol.io/specification/2026-07-28/basic/transports (Streamable HTTP and Origin validation), https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices, https://vercel.com/docs/sandbox and https://vercel.com/docs/sandbox/sdk-reference (Sandbox.create options: networkPolicy, persistent, image, timeout, resources), https://vercel.com/docs/mcp/deploy-mcp-servers-to-vercel, https://github.com/vercel/mcp-handler, and the reference implementation at examples/sandbox-isolation-server/ in this repository (a path relative to the repo root; the GitHub repository is private), compare against it if you get stuck.
GUARDRAILS
Tests must pass with no network access, no Vercel account, and no microVM (the stub, the type-only import, and the lazy runtime import guarantee this). No dependencies beyond the stack list; @vercel/sandbox is a dev dependency only and must never be imported as a value in src/. No env key in the sandbox options, no credential read from process.env, no redaction helper, no "****" masks in output. Keep it small: three source files, one route file, vercel.json, three test files.

Where to look now

  • Prompt index - all thirteen prompts and the reliability notes.
  • examples/sandbox-isolation-server (in the repository) - the reference implementation this prompt rebuilds.
  • Examples index - what each example demonstrates.

Bibliography