Skip to content

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

Prompt: query-command-server

Audience:engineernon-technicalMCP spec 2026-07-28

You get read-only query tools paired with a consent-gated, idempotency-keyed write command, with annotations a host can act on. It teaches the query vs command pattern.

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 query-command-server that runs locally and deploys to Vercel: an MCP (Model Context Protocol) server that pairs two read-only query tools with one state-changing command tool. The command is safe to retry thanks to an idempotency key, and every tool carries annotations so a host can apply light consent to reads and strong consent to the write.
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 devDependency (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), devDeps typescript, vitest, @types/node. Node 22 or newer. package.json has "type": "module" and scripts dev (next dev), test (vitest run), typecheck (tsc --noEmit).
LAYOUT
app/api/mcp/route.ts is a thin shell: build handler = createMcpHandler(configureServer, { serverInfo: { name: SERVER_NAME, version: SERVER_VERSION } }) from mcp-handler, wrap it as withOriginCheck(handler, parseAllowedOrigins(process.env[ALLOWED_ORIGINS_ENV])) from src/origin.ts, and export the wrapped function as GET, POST, DELETE. There is NO app/api/[transport]/ directory, no three-argument createMcpHandler signature, and no basePath option in mcp-handler 2.x (createMcpHandler survives with the two-argument form above); the public endpoint is /api/mcp. ALL protocol logic lives in src/server.ts exporting configureServer(server); src/origin.ts holds the Origin allowlist. Include a vercel.json with the $schema key and functions["app/api/mcp/route.ts"].maxDuration set to 30. Tests in tests/ (server.test.ts, origin.test.ts, vercel-config.test.ts) import src/ and never import Next.js.
BEHAVIOR
- src/origin.ts is the Origin allowlist (framework-free: Fetch Request in, Response or null out). Export ALLOWED_ORIGINS_ENV = "MCP_ALLOWED_ORIGINS"; DEFAULT_ALLOWED_ORIGINS = ["http://localhost:3000", "http://127.0.0.1:3000"]; parseAllowedOrigins(raw) (comma separated, each entry normalized to new URL(entry).origin, unparseable entries dropped, duplicates removed, defaults used when raw is undefined or blank); assertAllowedOrigin(request, allowlist) (no Origin header: return null and let it through; Origin present and on the allowlist: null; anything else, including the literal "null" origin: a 403 text/plain Response with body "Forbidden: Origin not allowed" that does not echo the allowlist); and withOriginCheck(handler, allowlist), which wraps a Fetch-style handler, preserves any extra parameters, and short-circuits with the refusal.
- State is module-level in src/server.ts: an items Map from id to { id, name } (export the Item interface), an appliedKeys Map from a principal-scoped idempotency key to the item id it created, and a monotonic counter nextId starting at 0. Ids are "item-1", "item-2", and so on from that counter, NEVER derived from items.size (a size-based id would collide once items are deleted). Export resetState() to clear both maps and the counter for tests, plus SERVER_NAME = "query-command-server", SERVER_VERSION = "0.1.0", MAX_NAME_LENGTH = 64, MAX_IDEMPOTENCY_KEY_LENGTH = 128, ANONYMOUS_PRINCIPAL = "anonymous", a ValidationError class, the zod schemas nameSchema and idempotencyKeySchema, and the helpers principalFromAuthInfo and scopedKey described below.
- The principal comes from the verified token, never from a tool argument. principalFromAuthInfo(authInfo: AuthInfo | undefined) (AuthInfo is a type import from @modelcontextprotocol/server) returns authInfo.extra.sub when it is a non-empty string, else authInfo.clientId when non-empty, else ANONYMOUS_PRINCIPAL (also when authInfo is undefined). scopedKey(principal, idempotencyKey) returns JSON.stringify([principal, idempotencyKey]), so the composite key stays unambiguous whatever characters either part contains. Anonymous callers share one replay namespace with each other and nobody else, so an authenticated principal's key can never be replayed from an unauthenticated request.
- Every inputSchema is a FULL zod object schema, z.object({ ... }); the v1 raw-shape form is gone in the v2 SDK.
- Tool list_items (query): inputSchema z.object({}), annotations { readOnlyHint: true, idempotentHint: true, destructiveHint: false }. Returns all items as a JSON array in one text content block, returning COPIES of the stored objects so a caller can never mutate server state through a result.
- Tool get_item (query): inputSchema z.object({ item_id: z.string() }), same annotations as list_items. Unknown id throws ValidationError with the id in the message; a found item is returned as a copy, JSON in a text block.
- Tool create_item (command): inputSchema z.object({ name: nameSchema, idempotency_key: idempotencyKeySchema }), where nameSchema = z.string().min(1).max(64).refine(no ASCII control characters) and idempotencyKeySchema = z.string().min(1).max(128), so the bounds are advertised in tools/list as minLength and maxLength; annotations { readOnlyHint: false, destructiveHint: true, idempotentHint: true }. Its description states it is consent-required, explains that idempotentHint reflects the key-replay guarantee, not the absence of side effects, and says the key is scoped to the calling principal so another principal sending the same key gets its own item.
- Validation happens BEFORE any state change, and it rejects rather than truncates: name must be non-empty, at most 64 characters, and contain no ASCII control characters (any code point below 0x20 or equal to 0x7f, so NUL, newlines, and escape are all rejected); idempotency_key must be non-empty and at most 128 characters (a clipped key would collide with a different caller's key). The SDK already parses arguments against the schemas, and the handler re-runs nameSchema.safeParse and idempotencyKeySchema.safeParse as defense in depth, throwing ValidationError with the first issue's message; the SDK turns the throw into an isError tool result.
- Idempotency replay, first write wins and scoped to the principal: the handler takes (args, ctx), resolves principal = principalFromAuthInfo(ctx.http?.authInfo) and key = scopedKey(principal, idempotency_key). If key is already in appliedKeys, return a copy of the ORIGINAL item, ignoring the new payload entirely; do not mutate anything. Otherwise increment the counter, store the item, record the scoped key, and return a copy. A different principal replaying the same idempotency_key misses the cache and gets a fresh item of its own.
TESTS (vitest)
Connect a real Client (from @modelcontextprotocol/client) to an McpServer over InMemoryTransport.createLinkedPair() (both from @modelcontextprotocol/server), then listTools and callTool. Call resetState() in beforeEach. Assert at minimum:
- listTools returns exactly create_item, get_item, list_items, and the annotations round-trip: both queries show readOnlyHint true, idempotentHint true, destructiveHint false; create_item shows readOnlyHint false, destructiveHint true, idempotentHint true.
- idempotency_key appears in create_item's inputSchema properties and required list, and does NOT appear in either query's schema. create_item's inputSchema advertises the bounds: name has type string, minLength 1, maxLength 64; idempotency_key has type string, minLength 1, maxLength 128.
- Queries have no side effects: three list_items calls in a row all return []. After a create, get_item and list_items reflect the committed item.
- SDK v2 (2.0.0) reality checks: a call to unknown tool "nope" makes callTool REJECT with a protocol error whose message matches /not found/i (this CHANGED from v1, which returned isError results for unknown tools). Schema-invalid arguments on a KNOWN tool still come back as isError true tool RESULTS, callTool does not throw for those. Cover as isError results: unknown item_id; name empty; name of 65 x characters; a name containing a NUL character; a name containing an escape character (0x1b); empty idempotency_key; an idempotency_key of 129 characters; name passed as the number 42.
- In-memory wire note: these tests run over InMemoryTransport, where a bare McpServer answers server/discover with -32601 and the Client defaults to the legacy initialize handshake at protocol version 2025-11-25, so on this path results carry no resultType and list results no ttlMs/cacheScope. That is a property of the harness, not of the server: the same configureServer behind createMcpHandler serves the 2026-07-28 frames over HTTP. If you want to assert those fields, do it in an HTTP-level check against the handler, not in these in-memory tests.
- No partial state after rejections: list_items is still [] after all the bad create calls, and the rejected key was NOT consumed, so a corrected retry with the same key succeeds and yields { id: "item-1", name: "ok" }.
- Replay: same key twice returns the identical item and list length stays 1; a new key creates a second, distinct item. Conflicting payload under the same key (create "alice" with k1, then "bob" with k1) returns the original alice item, first write wins, list length 1.
- Five creates with five distinct keys yield five unique ids.
- Principal-scoped replay: to give a client an identity, wrap clientTransport.send after createLinkedPair so every message is sent with { ...options, authInfo } (the v2 SDK's InMemoryTransport.send accepts an authInfo option, which the server surfaces to handlers as ctx.http.authInfo, the same path withMcpAuth uses in production); a stub AuthInfo looks like { token, clientId: "test-app", scopes: [], extra: { sub: "user:a" } }. Assert that principal A creating "alpha" with key "shared-key" and principal B creating "bravo" with the same key yields two distinct items (B gets "bravo", list length 2), and each principal's own replay of "shared-key" still returns its own original. Assert that a client with no AuthInfo lands in the anonymous namespace: anonymous and principal A both using key "k1" get distinct items, each replays to its own, list length 2. Assert principalFromAuthInfo directly: undefined gives "anonymous"; clientId alone gives the clientId; extra.sub wins over clientId; a blank sub falls back to clientId; a blank clientId with no sub gives "anonymous".
- Origin allowlist (tests/origin.test.ts, direct calls with plain Fetch Request objects, no server): no Origin header passes; an allowlisted origin passes even when the allowlist entry carried a trailing slash or different case; a non-allowlisted origin, the literal "null" origin, and an unparseable origin each get a 403 whose body does not contain the allowlist; an empty allowlist refuses every Origin; withOriginCheck never invokes the wrapped handler on refusal and forwards extra arguments on success; parseAllowedOrigins falls back to the defaults for undefined and blank input, drops unparseable and duplicate entries, and yields an empty allowlist (not the default) for an all-junk value.
- tests/vercel-config.test.ts reads vercel.json as data and fails unless functions["app/api/mcp/route.ts"].maxDuration is a positive integer.
DEFINITION OF DONE
npm install, npm run typecheck, and npm test are all green. Then npm run dev and connect MCP Inspector (npx @modelcontextprotocol/inspector) with the Streamable HTTP transport to http://localhost:3000/api/mcp; create an item, replay the same key, and list. Optionally vercel deploy (note in-process state lasts only as long as the serverless instance, which is expected here); no environment variables are required. One optional variable, MCP_ALLOWED_ORIGINS (comma separated browser origins), matters only if a browser-based client will call the endpoint; non-browser clients send no Origin and are unaffected.
SOURCES
https://modelcontextprotocol.io/specification/2026-07-28/server/tools (tool annotations are defined here), https://vercel.com/docs/mcp/deploy-mcp-servers-to-vercel, https://github.com/vercel/mcp-handler, and the reference implementation at examples/query-command-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 and no Vercel account. No dependencies beyond the stack list; no database, the in-memory maps are the point. Identity never comes from a tool argument: the route here is unauthenticated (every caller lands in the anonymous namespace) and a real deployment adds withMcpAuth so ctx.http.authInfo carries a verified subject. Keep it small.

Where to look now

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

Bibliography