# Async Jobs

Canonical URL: https://vercel-mcp-reference.vercel.app/patterns/async-jobs/
Markdown: https://vercel-mcp-reference.vercel.app/patterns/async-jobs.md
Audience: engineer, architect, security, non-technical. MCP spec version: 2026-07-28. Last reviewed: 2026-08-26. Status: stable.

## Summary

A tool design pattern for long-running work: the [tool](https://vercel-mcp-reference.vercel.app/glossary/#tool) call kicks off a background job and returns a handle quickly. The [server](https://vercel-mcp-reference.vercel.app/glossary/#server) records [progress](https://vercel-mcp-reference.vercel.app/glossary/#progress-notification) the client can query while the job runs, supports cooperative [cancellation](https://vercel-mcp-reference.vercel.app/glossary/#cancellation) through a paired command, and exposes a separate tool to retrieve the final result by handle, idempotently.

On Vercel this pattern is not a nicety; it is forced. A [Vercel Function](https://vercel-mcp-reference.vercel.app/glossary/#vercel-function) invocation has a hard `maxDuration` ceiling, so any work that can outlive one invocation must leave the request. As of MCP **2026-07-28**, the protocol formalizes exactly this lifecycle as the official [`tasks` extension](https://modelcontextprotocol.io/extensions/tasks/overview) (`io.modelcontextprotocol/tasks`, SEP-2663), whose redesigned surface polls a handle the same way this pattern always has. Read the extension section first to see what the protocol now standardizes, then the portable pattern, which remains the broadly supported fallback wherever the extension is not negotiated, and then the Vercel mapping, which is where the real design work lives.

## Problem addressed

A model issues a tool call that takes ninety seconds. Blocking the [JSON-RPC](https://vercel-mcp-reference.vercel.app/glossary/#json-rpc) request for the full duration burns a connection, hides progress from the user, prevents cancellation, and turns transient network blips into total work loss. Many backends (long queries, report generation, code execution, large file operations, agent sub-tasks) exceed any reasonable per-request timeout.

On Vercel the timeout is not hypothetical: `maxDuration` caps every invocation at 300 seconds on Hobby and 800 seconds on Pro and Enterprise (an extended 1800-second per-function option is in beta). When the clock runs out, Vercel terminates the function mid-work. The async-jobs pattern decouples "start the work" from "get the result": the start call returns within one invocation, the work runs somewhere durable, and the model can interleave other work while the job runs.

## When to use

- Work routinely takes more than a few seconds and may take minutes, or may exceed your plan's `maxDuration` at all.
- The user benefits from visible progress (counts, percentages, log lines).
- The work can be interrupted cleanly, and the user may want to cancel it.
- The work must survive the originating request: the job continues until told otherwise, or completes independently.
- The agent needs to do other things while the job runs.

## When not to use

- The work completes in well under a second. Async adds latency and handle bookkeeping for no benefit.
- The backend has no way to report progress or be cancelled cleanly. A fake async wrapper around a blocking call is worse than an honest blocking call.
- Result retrieval cannot be made idempotent. A handle that returns the result once and then fails is a footgun.
- The work has externally visible side effects on start that the user cannot easily undo; async makes "I changed my mind" feel safe when it is not.

## Architecture / flow diagram

```mermaid
sequenceDiagram
    autonumber
    participant Host
    participant Client
    participant Server
    participant Worker

    Host->>Client: tools/call start_job
    Client->>Server: tools/call start_job
    Server->>Worker: enqueue job(id=J)
    Server-->>Client: result { job_id: J, status: queued }
    Client-->>Host: render queued

    loop while running
        Worker-->>Server: write progress to job store (30%)
        Host->>Client: tools/call get_job_status(J)
        Client->>Server: tools/call get_job_status(J)
        Server-->>Client: result { status: running, progress: 30% }
        Client-->>Host: update UI
    end

    Host->>Client: tools/call get_job_result(J)
    Client->>Server: tools/call get_job_result(J)
    Server-->>Client: result { status: done, payload }
    Client-->>Host: render result
```

## The official tasks extension (2026-07-28)

MCP 2026-07-28 promotes tasks from an experimental core utility to an **official extension**, [`io.modelcontextprotocol/tasks`](https://modelcontextprotocol.io/extensions/tasks/overview) (SEP-2663, covered in depth in [internals/Tasks](https://vercel-mcp-reference.vercel.app/internals/tasks/)), negotiated through the new `extensions` capability field rather than assumed of every implementation. It lifts the start/poll/retrieve lifecycle out of application-level tool conventions and into protocol-managed machinery: a long-running request becomes a task with a protocol-minted identifier, a status the [client](https://vercel-mcp-reference.vercel.app/glossary/#client) polls, and a deferred result the client retrieves once the task completes.

The 2026-07-28 redesign converges on exactly the shape this pattern has always recommended:

- The blocking `tasks/result` call is gone; the client **polls `tasks/get`**, which is this pattern's poll-a-handle retrieval query made protocol-native.
- **`tasks/update`** carries client-to-server input mid-job, covering the "the job needs more input partway through" case the hand-rolled shape had to bolt on as an extra tool.
- `tasks/list` is removed; a client tracks the handles it was given, just as it tracks the opaque job handles below.
- Servers may return task handles **unsolicited** from any request, which legitimizes what this pattern always did: any tool may answer "that will take a while, here is a handle."
- **`tasks/cancel`** covers cooperative cancellation of protocol-managed tasks.

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. See [internals/Tasks](https://vercel-mcp-reference.vercel.app/internals/tasks/) for what the SDK exposes today and why this repo has no runnable native-tasks example yet: the pinned handler can negotiate the extension through `server/discover`, but the SDK ships no task runtime behind it. Where the extension is not negotiated (the peer does not advertise it, or the stack does not implement it), the hand-rolled pattern below remains the portable choice, and understanding the hand-rolled shape is the clearest way to understand what the extension formalizes. When you do adopt the extension, the same security obligations apply: task identifiers must be unguessable and principal-scoped, retrieval must re-check authorization, and results are still tool outputs that must be sanitized before re-entering the model context.

## The portable pattern

> The mechanics below are the illustrative, hand-rolled form of this pattern: paired tools plus a server-generated handle, with progress and cancellation carried as job state through the same tool surface. The tasks extension formalizes the same lifecycle; this section is accurate today and portable across peers that do not negotiate the extension.

- The starting tool call returns quickly with a job handle (an opaque, server-generated string). It does not block on the job.
- Progress is state, not a stream: the worker writes progress to the job store, and the client reads it through a status query (for example, `get_job_status`) keyed by handle. In-band `notifications/progress` against the starting call's progress token is only possible while that request is still open: the [2026-07-28 progress spec](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/progress) requires progress notifications to reference an in-progress request and to stop once it completes, and on [Streamable HTTP](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http) they flow only on the originating request's response stream, which the final result terminates. Because the starting call returns immediately, it cannot carry progress for a job that outlives it. Progress is informational; it never carries the final result.
- The server exposes a paired retrieval tool (for example, `get_job_result`) keyed by handle. Retrieval must be idempotent: calling it after completion always returns the same result until the handle is garbage-collected.
- Input-validation failures on either tool (a malformed handle, an unknown handle, an out-of-range parameter on the starting call) surface as **tool execution errors**: a `tools/call` result with `isError: true`, not a JSON-RPC protocol error. Per SEP-1303, carried forward in the [2026-07-28 tools spec](https://modelcontextprotocol.io/specification/2026-07-28/server/tools), this lets the model see the rejection and self-correct, for example by re-issuing retrieval with a corrected handle. Reserve protocol errors for genuine transport- or method-level faults.
- Cancellation is a paired command tool (for example, `cancel_job`) keyed by handle: it sets a flag in the job store that the worker checks cooperatively between steps, stops in-flight work, and rolls back partial side effects where feasible. Protocol-level [cancellation](https://vercel-mcp-reference.vercel.app/glossary/#cancellation) cannot reach the job: per the [2026-07-28 cancellation spec](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation), cancellation targets only requests still in progress, and the starting call has already returned (on Streamable HTTP the client cancels an in-flight request by closing its response stream; `notifications/cancelled` is a stdio mechanism). Cancellation of the job is best-effort; the server documents what is and is not guaranteed.
- Handle lifetime, expiry, and cleanup must be documented: both how long a result is retained and how the server signals that a handle has expired.
- The retrieval tool is a query in the [query-vs-command](https://vercel-mcp-reference.vercel.app/patterns/query-vs-command/) sense; the starting tool is a command. Annotate both accordingly.

## Vercel mapping

`maxDuration` is the forcing function. Hobby caps every invocation at 300 seconds; Pro and Enterprise at 800 seconds, with a per-function extended option of 1800 seconds in beta. An MCP tool call is one invocation, so any job that can exceed the ceiling cannot run inside the `tools/call` that started it. On Vercel, async jobs is the only honest shape for long work; everything else is a timeout with extra steps.

```mermaid
flowchart LR
    C[MCP client] -->|tools/call start_job| A[MCP Function]
    A -->|send topic jobs| Q[Queue topic]
    Q -->|push callback| W[Consumer Function]
    W --> S[(Job store)]
    A -->|status and result reads| S
```

- **Queues (public beta) for the queue plus worker.** The start tool publishes with `send(topic, payload)` from `@vercel/queue`; a separate route consumes in push mode via `handleCallback`. The consumer is wired in `vercel.json`, inside the route's `functions` entry, with exactly this syntax:

  ```json
  {
    "functions": {
      "app/api/queues/process-job/route.ts": {
        "experimentalTriggers": [{ "type": "queue/v2beta", "topic": "jobs" }]
      }
    }
  }
  ```

  The trigger makes the consumer route private: it has no public URL, and only Vercel's queue infrastructure can invoke it. Queues redelivers on crash, so the worker must be idempotent: check the job's status in the store before doing work, and make each step safe to repeat.
- **Workflows for durable multi-step jobs.** [Vercel Workflows](https://vercel.com/docs/workflows) (generally available since 2026-04-16) builds on Queues and adds durable steps, sleep, and hooks, with state that survives for minutes to months and no duration limits. If your job is a pipeline rather than a single unit of work, start there instead of hand-chaining queue messages.
- **Cron for the sweep.** A [cron job](https://vercel.com/docs/cron-jobs) declared in `vercel.json` periodically expires stale handles, garbage-collects retained results, and flags jobs stuck in `running` past their deadline. The sweep is what makes "handle lifetime is documented" true in practice.
- **The job store is external, always.** The job record (handle, principal, status, progress, result, expiry) must live in Marketplace [Redis](https://vercel.com/docs/redis) or [Postgres](https://vercel.com/docs/postgres), or in [Blob](https://vercel.com/docs/vercel-blob) for large result payloads. [Fluid compute](https://vercel-mcp-reference.vercel.app/glossary/#fluid-compute) instance reuse is best-effort, never a correctness guarantee: a job table in module scope evaporates on the next cold start and was never visible to the consumer function anyway. See [Serverless sessions](https://vercel-mcp-reference.vercel.app/internals/serverless-sessions/).
- **Progress and cancellation become state, not streams.** In-band `notifications/progress` works only while the starting invocation is alive; once the work moves to a queue consumer, there is no open response to stream through. The serverless shape: the worker writes progress to the job store, a status query reads it, and cancellation is a `cancel_job` command that sets a flag the worker checks cooperatively between steps. Be honest in your tool descriptions about which of the two you implement.

## Security considerations

- Job handles must be unguessable (at least 128 bits of entropy from a CSPRNG) and scoped to the principal that started the job. Another user must never be able to retrieve someone else's result. See [Session handling](https://vercel-mcp-reference.vercel.app/security/checklist/#session-handling).
- Authorization applies on every retrieval, not only on the start call: a principal that loses access mid-job must not be able to fetch the result. See [Authorization & scoping](https://vercel-mcp-reference.vercel.app/security/checklist/#authorization--scoping).
- Cancellation must actually stop the work, not just hide it from the client; a cleared UI over a still-running worker is a lie with a bill attached. See [Session handling](https://vercel-mcp-reference.vercel.app/security/checklist/#session-handling).
- Cap per-principal job concurrency, queue depth, and retained-result volume at the server. An unauthenticated start tool on a public URL is a free compute API. See [Session handling](https://vercel-mcp-reference.vercel.app/security/checklist/#session-handling).
- Job results are tool outputs and must be sanitized and tagged like any other tool output before re-entering the model context. See [Output trust](https://vercel-mcp-reference.vercel.app/security/checklist/#output-trust).
- Keep the worker off the public surface: the queue trigger already makes the consumer route private, so never mount the same handler on a public route, and never accept a queue-shaped payload from the open internet. See [Trust boundaries](https://vercel-mcp-reference.vercel.app/security/checklist/#trust-boundaries).
- The job-store connection string is a sensitive, environment-scoped variable; [preview deployments](https://vercel-mcp-reference.vercel.app/glossary/#preview-deployment) get their own store (or none), never production's job records. See [Deployment posture](https://vercel-mcp-reference.vercel.app/security/checklist/#deployment-posture).
- Log job start, progress, cancellation, completion, and retrieval with the principal and a handle hash; treat long-running jobs as audit-worthy commands. See [Monitoring & audit](https://vercel-mcp-reference.vercel.app/security/checklist/#monitoring--audit).

## Example implementation

- `examples/async-jobs-server` (in the repository) - a runnable implementation of the portable pattern. Four tools registered by `configureServer` in `src/server.ts`: `submit_job` (command) mints an opaque `randomBytes(16).toString("base64url")` handle and returns before doing any work; `get_job_status` (query) reports `state`, `completedSteps`, and `totalSteps`; `get_job_result` (query, idempotent) returns the same payload on every call after `done`, as a defensive copy so a caller cannot mutate stored state; `cancel_job` (command, idempotent) sets a `cancelRequested` flag that the driver honors between steps. Every job records the principal that submitted it, derived from `ctx.http.authInfo` by `principalFromAuthInfo` (subject claim, then OAuth client id, then `ANONYMOUS_PRINCIPAL`), never from a tool argument, and the three handle-taking tools look a handle up under the calling principal only: a foreign handle raises the same `UnknownJobError` with the same message as one that never existed. Caps are per principal, not per session: `MAX_ACTIVE_JOBS_PER_PRINCIPAL` (8) bounds pending-plus-running jobs for a verified principal, `MAX_ACTIVE_JOBS_ANONYMOUS` (2) applies to unauthenticated callers, `MAX_JOBS_TOTAL` (256) is a server-wide backstop, and `MAX_STEPS` (100) caps the work per job. Finishing or cancelling a job frees its slot at once; done and cancelled jobs are evicted after `JOB_RETENTION_MS` (five minutes) measured by an injectable clock (`setClock`), so expiry needs no timers. The "long-running" work is a fixed step count driven by the exported non-tool functions `advance` and `runToCompletion` (no wall-clock sleeps), so `tests/server.test.ts` runs offline: it covers ownership over the wire from a stub `AuthInfo`, the per-principal cap with no partial state, capacity freed before the TTL, eviction at exactly the TTL on a fake clock, mid-flight cancellation from inside the progress callback, and the SDK v2 error semantics. The queue side is real in shape and stubbed in behavior: `vercel.json` attaches the `experimentalTriggers` entry above to the private consumer route `app/api/queues/process-job/route.ts` (the MCP route carries only `maxDuration`, never a trigger), that route exports `handleJobMessage` from `src/consumer.ts`, which validates a `{ jobId, ownerId, steps }` message against `jobMessageSchema` and acknowledges with 200 or answers 400, and `tests/queue-consumer.test.ts` fails if the trigger ever moves onto `/api/mcp`. `@vercel/queue` is deliberately not a dependency; the production `handleCallback` wiring is shown in a comment, and the README links the Workflows alternative. The only environment variable is the optional `MCP_ALLOWED_ORIGINS` allowlist from `src/origin.ts`.
- `examples/secure-tools-server` (in the repository) - a useful companion for the consent and output-minimization controls a job-submitting command needs, but it is synchronous and does not demonstrate progress, cancellation, or handles.

## Trade-offs

| Pros | Cons |
|---|---|
| Long-running work survives `maxDuration`, reconnects, and redeploys. | Two tools per operation (start, retrieve) plus handle bookkeeping. |
| The model can do other things while the job runs. | You now run a queue consumer and a durable job store; deployment is more complex than a synchronous tool. |
| Connection blips and function timeouts do not destroy work. | Handle lifetime, expiry, and authorization must be designed explicitly. |
| Progress and cancellation give the user real control. | Cancellation is cooperative and best-effort; partial side effects may persist, and Queues redelivery demands an idempotent worker. |

## Related patterns

- [query-vs-command](https://vercel-mcp-reference.vercel.app/patterns/query-vs-command/) - the starting tool is a command; the retrieval tool is a query. The split applies, annotations included.
- [adapter](https://vercel-mcp-reference.vercel.app/patterns/adapter/) - async is the right shape for adapters wrapping backends with long-running APIs (queries, reports, batch jobs).
- [sidecar](https://vercel-mcp-reference.vercel.app/patterns/sidecar/) - job work that is heavier or less trusted than the server belongs in the sidecar shape; on Vercel that means Sandbox.
- [least-privilege](https://vercel-mcp-reference.vercel.app/patterns/least-privilege/) - job retrieval must enforce the same scoping as the originating call.
- [trust-boundaries](https://vercel-mcp-reference.vercel.app/patterns/trust-boundaries/) - handles must not become a side channel to other principals' results, and the worker route is a boundary of its own.

## Vercel deployment (Terraform)

An illustrative Vercel expression of the durable half of this pattern lives in `terraform/patterns/async-jobs` (in the repository): a project, a cron for the sweep, and a sensitive environment variable for the job-store connection, built with the official `vercel/vercel` provider. Queue topics and triggers are not Terraform-manageable; the README shows the `vercel.json` expression instead. It is `tofu validate`-checked, never applied in CI. See `terraform/README.md` (in the repository) for scope and caveats.

## Bibliography

- Model Context Protocol, *Tasks Extension* - <https://modelcontextprotocol.io/extensions/tasks/overview>
- Model Context Protocol, *Extensions Overview* - <https://modelcontextprotocol.io/extensions/overview>
- Model Context Protocol Specification, *Changelog*, version 2026-07-28 - <https://modelcontextprotocol.io/specification/2026-07-28/changelog>
- Model Context Protocol Specification, *Progress*, version 2026-07-28 - <https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/progress>
- Model Context Protocol Specification, *Cancellation*, version 2026-07-28 - <https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation>
- Model Context Protocol Specification, *Tools*, version 2026-07-28 - <https://modelcontextprotocol.io/specification/2026-07-28/server/tools>
- Model Context Protocol Specification, *Streamable HTTP Transport*, version 2026-07-28 - <https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http>
- Vercel Documentation, *Vercel Queues* - <https://vercel.com/docs/queues>
- Vercel Documentation, *Queues Quickstart* - <https://vercel.com/docs/queues/quickstart>
- Vercel Documentation, *Vercel Workflows* - <https://vercel.com/docs/workflows>
- Vercel Blog, *A new programming model for durable execution* (Workflows general availability, 2026-04-16) - <https://vercel.com/blog/a-new-programming-model-for-durable-execution>
- Vercel Documentation, *Cron Jobs* - <https://vercel.com/docs/cron-jobs>
- Vercel Documentation, *Configuring Maximum Duration for Vercel Functions* - <https://vercel.com/docs/functions/configuring-functions/duration>
- Vercel Documentation, *Fluid compute* - <https://vercel.com/docs/fluid-compute>
- Vercel Documentation, *Vercel Blob* - <https://vercel.com/docs/vercel-blob>
- Vercel Documentation, *Redis* - <https://vercel.com/docs/redis>
- Vercel Documentation, *Postgres* - <https://vercel.com/docs/postgres>
