Community-maintained FDE reference. Not an official Vercel or Anthropic project. About this project
Prompt: async-jobs-server
You get a deployable MCP server that runs long jobs behind opaque handles with progress, cooperative cancellation, and idempotent result retrieval, teaching the async jobs 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.
GOALBuild me a small TypeScript project called async-jobs-server that runs locally and deploys to Vercel: an MCP (Model Context Protocol) server that demonstrates the async-jobs pattern. A command tool starts a long-running job and returns an opaque handle immediately, the server can report per-step progress, a cancel tool stops the job cooperatively, and a query tool fetches the final result idempotently by handle.
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), 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, and @modelcontextprotocol/client 2.0.0 (tests only). Node 22 or newer. package.json has "type": "module" and scripts dev (next dev), test (vitest run), typecheck (tsc --noEmit).
LAYOUTapp/api/mcp/route.ts is a thin shell: build createMcpHandler(configureServer, { serverInfo: { name: SERVER_NAME, version: SERVER_VERSION } }) from mcp-handler, wrap it with withOriginCheck(handler, parseAllowedOrigins(process.env[ALLOWED_ORIGINS_ENV])) from src/origin.ts, and export the wrapped handler as GET, POST, DELETE. (There is no [transport] directory, no three-argument createMcpHandler, and no basePath option in mcp-handler 2.x; the public endpoint is /api/mcp.) ALL protocol logic lives in src/server.ts, which exports SERVER_NAME, SERVER_VERSION, configureServer(server), the constants, error classes, and plain functions below, and is the package.json "exports" entry. src/origin.ts is the house-style Origin allowlist (framework-free: Fetch Request in, Response or null out): exports ALLOWED_ORIGINS_ENV = "MCP_ALLOWED_ORIGINS", DEFAULT_ALLOWED_ORIGINS = ["http://localhost:3000", "http://127.0.0.1:3000"], parseAllowedOrigins(raw), assertAllowedOrigin(request, allowlist) (403 for a non-allowlisted Origin, null for no Origin or an allowed one), and withOriginCheck(handler, allowlist). The queue side has two more files: src/consumer.ts (the framework-free consumer stub, exports below) and app/api/queues/process-job/route.ts, which does nothing but import handleJobMessage from src/consumer.ts and export it as POST. The MCP route never imports the consumer. vercel.json has the $schema key and a functions block with exactly two entries: "app/api/mcp/route.ts": { "maxDuration": 30 } (maxDuration only, no trigger) and "app/api/queues/process-job/route.ts": { "maxDuration": 60, "experimentalTriggers": [{ "type": "queue/v2beta", "topic": "jobs" }] }. The trigger is what makes the consumer route private (no public URL, only Vercel Queues can invoke it), so it must never appear on the MCP route. Tests live in tests/ (server.test.ts, queue-consumer.test.ts, origin.test.ts, vercel-config.test.ts), import only from src/ and vercel.json, and never import Next.js.
BEHAVIOR- Constants exported from src/server.ts: MAX_ACTIVE_JOBS_PER_PRINCIPAL = 8 (cap on pending-plus-running jobs per verified principal), MAX_ACTIVE_JOBS_ANONYMOUS = 2 (the smaller cap for the anonymous principal), MAX_JOBS_TOTAL = 256 (server-wide backstop on registry size across all principals), MAX_STEPS = 100 (per-job step cap), JOB_RETENTION_MS = 5 * 60 * 1000 (how long a done or cancelled job stays retrievable), ANONYMOUS_PRINCIPAL = "anonymous". Job states: pending, running, done, cancelled. Each Job record holds jobId, ownerId (the submitting principal, stored but never returned to clients), state, totalSteps, completedSteps, result, cancelRequested, and finishedAt (clock reading when it reached done or cancelled, null while active). Jobs live in an exported in-memory Map named jobs, with an exported resetJobs() for tests.- Principals: export principalFromAuthInfo(authInfo) which returns "sub:" + authInfo.extra.sub when that is a non-empty string, else "client:" + authInfo.clientId when clientId is non-empty, else ANONYMOUS_PRINCIPAL (also when authInfo is undefined). Every tool handler derives its principal from ctx.http.authInfo (the slot withMcpAuth fills in production); identity NEVER comes from tool arguments, and the zod schemas strip any principal or ownerId key a client sends. Export capFor(principal) (MAX_ACTIVE_JOBS_ANONYMOUS for the anonymous principal, else MAX_ACTIVE_JOBS_PER_PRINCIPAL) and activeJobCount(principal) (pending or running jobs owned by that principal).- Ownership: get_job_status, get_job_result, and cancel_job look the handle up under the calling principal only. Another principal's handle throws the same UnknownJobError with the same message ("unknown job handle") as a handle that never existed or has been evicted, so nothing about foreign handles leaks. The driver functions advance and runToCompletion are trusted server code and look handles up without an ownership check.- Retention: done and cancelled jobs are evicted once now - finishedAt >= JOB_RETENTION_MS; an evicted handle reads as unknown. Export evictExpired() (returns the count evicted) and call it on entry to every registry operation, so eviction is a pure function of the clock with no timers. Nothing calls Date.now() directly: read time through an injectable clock, exported as setClock(fn | null) (null restores the wall clock; resetJobs() also restores it). Repeated cancels and reads must not refresh finishedAt, so they cannot extend retention.- Real work is simulated by a fixed step count. An exported advance(jobId, n = 1) function (NOT an MCP tool) drives a job forward: first advance moves pending to running, each step increments completedSteps, reaching totalSteps sets state done, freezes the result, and stamps finishedAt. It stops early if cancelRequested is set or the job is already done or cancelled. No timers, no sleeps anywhere.- Handles are opaque and unguessable: randomBytes(16).toString("base64url") from node:crypto.- The deterministic result of a job with N total steps is { sum: 0 + 1 + ... + (N - 1), stepsRun: completedSteps }.- Error classes: JobError for validation and limit failures, UnknownJobError (extends JobError) for a handle that is unknown, foreign, or expired.- The plain functions take the principal as a second argument: submitJob(steps, principal), getJobStatus(jobId, principal), getJobResult(jobId, principal), cancelJob(jobId, principal). Four tools wrap them, each returning JSON.stringify of its payload as a single text content item. inputSchema is a FULL zod object schema, e.g. inputSchema: z.object({ steps: z.number().int() }); the v1 raw-shape form is gone in the v2 SDK. 1. submit_job (command), inputSchema z.object({ steps: z.number().int() }), annotations { readOnlyHint: false, idempotentHint: false }. Validates, in order, that steps is an integer between 1 and MAX_STEPS, that activeJobCount(principal) is below capFor(principal), and that the registry holds fewer than MAX_JOBS_TOTAL jobs, THEN creates a pending job owned by the principal, so a rejected submit leaves no partial state. Returns { jobId, state, totalSteps } immediately without doing any work (never ownerId). 2. get_job_status (query), inputSchema z.object({ jobId: z.string() }), annotations { readOnlyHint: true }. Returns { jobId, state, completedSteps, totalSteps }. 3. get_job_result (query, idempotent), inputSchema z.object({ jobId: z.string() }), annotations { readOnlyHint: true, idempotentHint: true }. Returns { jobId, state, result } where result is null until done. It must return a defensive copy so a caller mutating the payload cannot corrupt stored state. An unfinished or cancelled job is NOT an error; only an unknown handle is. 4. cancel_job (command, best-effort, idempotent), inputSchema z.object({ jobId: z.string() }), annotations { readOnlyHint: false, idempotentHint: true }. Sets cancelRequested and moves a pending or running job to cancelled (stamping finishedAt), which frees the principal's slot immediately; a done job stays done. Returns { jobId, state, completedSteps, totalSteps }. Calling it twice is safe.- Export runToCompletion(jobId, progress): loops advance(jobId, 1) while the job is pending or running, breaking if cancelRequested, and after each step awaits progress(completedSteps, totalSteps, "step X/Y"). This callback is where notifications/progress would be sent over a real transport. Cancellation is cooperative: advance and runToCompletion both check the flag between steps.- Queue consumer stub in src/consumer.ts: export JOBS_TOPIC = "jobs" (the same topic name as the vercel.json trigger); jobMessageSchema = z.object({ jobId: z.string().min(1), ownerId: z.string().min(1), steps: z.number().int().min(1).max(MAX_STEPS) }) and its inferred JobMessage type (the message a production submit_job would publish with send(JOBS_TOPIC, { jobId, ownerId, steps })); parseJobMessage(body) returning the parsed message or null; and handleJobMessage(request: Request): Promise<Response>, which answers 400 for a non-JSON body or a message that fails the schema and 200 with an empty body for a valid one. It does no work: the example's job driver is in-memory and test-driven, so nothing publishes to or consumes from a real queue and @vercel/queue is deliberately NOT a dependency. Put the production wiring in a comment inside the function: import handleCallback from @vercel/queue, export POST = handleCallback(async (message) => { ... }, { topic: JOBS_TOPIC }), check the external job store first because Queues redelivers on crash, do the steps while honoring the cancel flag, and persist progress and the result where the status and result tools read them.
TESTS (vitest)Connect a real Client (from @modelcontextprotocol/client) to a real McpServer over InMemoryTransport.createLinkedPair() (both from @modelcontextprotocol/server), then use listTools and callTool. Inject principals the way production does: for an authenticated client, wrap clientTransport.send so every message is sent with { ...options, authInfo }, where authInfo is a stub AuthInfo such as { token: "token-alice", clientId: "test-client", scopes: [], extra: { sub: "alice" } }; the SDK surfaces it to handlers as ctx.http.authInfo. Connect without a wrapper for an anonymous client. Call resetJobs() in beforeEach. Where the TTL matters, install a fake clock with setClock that starts at a fixed epoch and is advanced by hand. Cover at least:- tests/server.test.ts: - principal derivation: principalFromAuthInfo prefers the subject claim ("sub:alice"), then the client id ("client:app-1"), then ANONYMOUS_PRINCIPAL (for an empty clientId and empty sub, and for undefined); capFor gives the anonymous principal MAX_ACTIVE_JOBS_ANONYMOUS, a verified principal MAX_ACTIVE_JOBS_PER_PRINCIPAL, and the anonymous cap is strictly smaller. - listTools returns all four tools, submit_job's inputSchema has properties.steps.type equal to 'integer', and no tool's inputSchema has a principal or ownerId property. - submitJob returns unique pending handles, records the owner in the jobs Map, and never returns ownerId; it throws JobError for steps 0, steps MAX_STEPS + 1, and steps 1.5. - The cap test: after submitting capFor(A) jobs as principal A, the next submit throws JobError AND jobs.size and activeJobCount(A) still equal the cap (no partial state). A at its cap does not block B: B's submit succeeds and jobs.size is capFor(A) + 1. The anonymous principal is refused after MAX_ACTIVE_JOBS_ANONYMOUS submits. Capacity is freed as soon as a job finishes, before any TTL elapses: with A at the cap, runToCompletion on one job drops activeJobCount(A) by one and lets one more submit through, and cancelJob frees a slot the same way. - SDK v2 (2.0.0) reality check: a handler throw on a KNOWN tool still resolves callTool with isError true, not a protocol error; callTool({ name: 'submit_job', arguments: { steps: 0 } }) resolves with isError true, and the same holds for get_job_status, get_job_result, and cancel_job called with jobId 'bogus'. Schema-invalid arguments on a known tool (steps: 'three') also resolve with isError true. BUT an unknown tool name (callTool({ name: 'nope' })) now REJECTS with a protocol error matching /not found/i; this changed from v1, which returned isError results, and v2 matches the spec. - Ownership, direct calls: after A submits and advances a job two steps, getJobStatus, getJobResult, and cancelJob as B all throw UnknownJobError, the message B gets for A's handle equals the message for "bogus", and A's job is untouched (still running, completedSteps 2, cancelRequested false). - Ownership over the wire: Alice's client submits; Bob's client calls get_job_status, get_job_result, and cancel_job with { jobId, principal: A, ownerId: A } and gets isError true with text containing "unknown job handle" and not containing Alice's principal; an anonymous client gets isError true on get_job_status; Alice still sees the job as pending. - Retention: on a fake clock, complete one job, tick 1000 ms, cancel another, leave a third active; at JOB_RETENTION_MS - 1 ms after the first finished, evictExpired() returns 0 and both finished jobs still read; one tick later the done job throws UnknownJobError while the cancelled and active jobs still read and jobs.size is 2; 1000 ms later the cancelled job is gone (jobs.size 1); the active job survives JOB_RETENTION_MS * 10. Repeated cancels and reads do not extend retention. The default clock is the wall clock, and resetJobs() restores it after setClock. - runToCompletion with a recording progress callback: a 5-step job produces exactly 5 monotonically increasing calls [1,2,3,4,5] all with total 5, and ends done. A 7-step job yields result sum 21, stepsRun 7. - Mid-flight cancellation: a progress callback that calls cancelJob on its 2nd invocation stops a 10-step job at completedSteps 2 with exactly 2 progress calls, state cancelled, result null. - After cancelJob, advance must not move the job forward, cancelJob is idempotent (second call still reports cancelled), and cancelling a done job does not un-finish it (state stays done, result still { sum: 1, stepsRun: 2 } for a 2-step job). - get_job_result is idempotent after done, and mutating the returned result object (change sum, add a key) does not affect a subsequent read (sum still 15 for 6 steps, no injected key). - Unknown handles throw UnknownJobError from getJobStatus, getJobResult, cancelJob, and advance when called directly, and surface as isError results over the wire. - Full round trip over the wire: submit 4 steps (state pending, no ownerId in the payload), runToCompletion, status shows done with completedSteps 4, result equals { sum: 6, stepsRun: 4 }. An anonymous client can submit MAX_ACTIVE_JOBS_ANONYMOUS jobs, the next submit is isError true, and activeJobCount for a verified principal is still 0.- tests/queue-consumer.test.ts: read vercel.json as data and assert the MCP route entry has no experimentalTriggers and exactly the keys ["maxDuration"]; the consumer route entry has a numeric maxDuration and exactly one trigger equal to { type: "queue/v2beta", topic: JOBS_TOPIC }; the trigger is mounted on exactly one route. Then exercise the stub directly with Fetch Request objects: parseJobMessage accepts { jobId: "h", ownerId: "sub:a", steps: 3 } and returns null for null, {}, an empty jobId, an empty ownerId, steps 0, steps 1.5, and steps MAX_STEPS + 1; handleJobMessage answers 200 with an empty body for a valid message and 400 for a non-JSON body and for { nope: 1 }.- tests/origin.test.ts: the house-style Origin checks against src/origin.ts with plain Fetch Request objects (a foreign Origin is 403 without echoing the allowlist, no Origin passes, an allowlisted Origin passes, the literal "null" and unparseable values are refused, scheme, host, and port all count while case is normalized, an empty allowlist refuses every Origin, parseAllowedOrigins falls back to the default only when the variable is unset or blank and yields [] for all-junk input, and withOriginCheck short-circuits before the wrapped handler runs while forwarding extra arguments).- tests/vercel-config.test.ts: reads vercel.json and asserts a positive integer maxDuration on app/api/mcp/route.ts.- 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.
DEFINITION OF DONEnpm install, npm run typecheck, and npm test all green with no network. Then npm run dev and connect MCP Inspector (npx @modelcontextprotocol/inspector) with the Streamable HTTP transport to http://localhost:3000/api/mcp and exercise submit_job, get_job_status, cancel_job, and get_job_result by hand (with no bearer token you are the anonymous principal, capped at 2 active jobs). Optionally vercel deploy; the consumer route deploys alongside the MCP route and is simply never invoked unless Queues (public beta) is enabled with a "jobs" topic. No environment variables are required; the optional MCP_ALLOWED_ORIGINS (comma separated browser origins) matters only if a browser-based client will call the endpoint.
SOURCEShttps://modelcontextprotocol.io/specification/2026-07-28/server/tools, https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/progress, https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation, https://vercel.com/docs/mcp/deploy-mcp-servers-to-vercel, https://github.com/vercel/mcp-handler, and the reference implementation at examples/async-jobs-server/ in this repository (a path relative to the repo root; the GitHub repository is private); compare against it if you get stuck.
GUARDRAILSTests must pass with no network access and no Vercel account. No dependencies beyond the stack list (in particular, no @vercel/queue; the consumer is a stub with the production wiring in a comment). No timers or wall-clock sleeps anywhere in the job path; time is read only through the injectable clock. Identity comes only from ctx.http.authInfo, never from tool arguments. The queue trigger lives only on the consumer route, never on the MCP route. Keep it small: three source files (server, consumer, origin), two route files (the MCP shell and the consumer shell), four test files (server, queue-consumer, origin, vercel-config).Where to look now
- Prompt index - all thirteen prompts and the reliability notes.
examples/async-jobs-server(in the repository) - the reference implementation this prompt rebuilds.- Examples index - what each example demonstrates.
Bibliography
- localhost:3000 - http://localhost:3000/api/mcp
- Model Context Protocol Specification - https://modelcontextprotocol.io/specification/2026-07-28/server/tools
- Model Context Protocol Specification - https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/progress
- Model Context Protocol Specification - https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation
- Vercel Documentation - https://vercel.com/docs/mcp/deploy-mcp-servers-to-vercel
- vercel/mcp-handler, source code - https://github.com/vercel/mcp-handler
- Reference implementation, source code (this repository) -
examples/async-jobs-server(in the repository)