Skip to main content
FrontMCP implements the MCP 2025-11-25 tasks spec so clients can invoke long-running tools as durable, requestor-polled tasks instead of holding a request open for minutes. The client adds a task field to tools/call, gets back a CreateTaskResult containing a taskId, then polls tasks/get / blocks on tasks/result / can tasks/cancel at its leisure.
Tasks are marked experimental in the MCP 2025-11-25 spec. Field names and behavior may evolve. FrontMCP implements the receiver side for tools/call today; client-side task augmentation of sampling/createMessage and elicitation/create is tracked as a follow-up.

Why tasks

Without tasks, a slow tool call ties up the HTTP request until the tool returns — which breaks LLM flow-control, can exceed proxy/gateway timeouts, and can’t survive a connection blip. Tasks let the server:
  • Return control to the model immediately with a stable taskId
  • Allow the client to poll (or block) whenever it’s actually ready for the result
  • Deliver notifications/tasks/status updates on state transitions
  • Signal cancellation mid-flight with the standard tasks/cancel RPC

Quick start

1. Opt a tool into task invocation

tool.ts
execution.taskSupport is surfaced on tools/list items so clients know which code path to use:

2. Enable the task subsystem

main.ts
The tasks capability is advertised to clients during initialize:

3. Invoke from the client

Lifecycle

  • Tasks begin in working.
  • From working they may move to input_required, completed, failed, or cancelled.
  • From input_required they may move back to working, or to a terminal state.
  • Terminal is final — a task that becomes cancelled stays cancelled even if the underlying code keeps running.
A tool call that returns { isError: true } lands the task in failed. A tool that throws a JSON-RPC error (e.g. ToolNotFoundError) lands it in failed with the original error code/message replayed verbatim by tasks/result.

Cancellation

FrontMCP guarantees:
  1. The task record transitions to cancelled before the tasks/cancel response is sent.
  2. The runner’s cancellation hook fires, routing through one of three mechanisms depending on how the task is executing:
    • In-process runnerAbortController.abort() on the controller tracked in the TaskRegistry for the taskId. Tools observe it via this.signal on ToolContext.
    • In-process runner on a different node — the store publishes on the {keyPrefix}cancel:{taskId} channel (Redis/Upstash only). The node actually running the task subscribes and fires its local AbortController.
    • CLI runnerprocess.kill(executor.pid, 'SIGTERM') sent to the detached worker. The worker’s own SIGTERM handler calls AbortController.abort() so this.signal fires in the tool exactly as it would for in-process execution.
  3. A second tasks/cancel on a terminal task returns -32602 (Invalid params) per spec — this is checked in TasksCancelFlow before any runner work happens.
Writing a cancel-aware tool is just observing the signal:

Blocking on the result

tasks/result blocks until the task reaches a terminal state, then replays exactly the response the underlying request would have produced. This is the spec-mandated “block-until-ready” pattern for clients that don’t want to poll.
Internally the flow subscribes to the store’s pub/sub terminal channel. With Redis / Upstash, a tasks/result issued on node A is unblocked as soon as the task finishes on node B. With SQLite the pub/sub is same-process only — if the reader is a different process than the worker, the blocking call relies on the post-subscribe re-check plus periodic tasks/get-style polling. When in doubt across processes, poll tasks/get explicitly; tasks/result then returns instantly once the record is terminal.

Listing tasks

The list is scoped to the calling session. Tasks created by a different session are invisible (tasks/get, tasks/result, and tasks/cancel all return -32602 Invalid params for foreign taskIds — matching spec §Security). The tasks.list capability is only advertised when requestors can be identified. Servers that run without any auth / session binding SHOULD set tasks.enabled: true but expect their clients to treat tasks.list as best-effort.

Notifications

Servers MAY push notifications/tasks/status when a task’s state changes. The message carries the full task wire shape:
FrontMCP emits one on initial creation (on the same SSE stream as the CreateTaskResult response) and on every transition to a terminal state. Clients MUST NOT rely on notifications arriving; per spec they are optional. Keep polling tasks/get as the source of truth.

Storage & distribution

The task store ships with three backends. Pick based on your deployment topology:

Redis / Upstash example

Key layout (memory / Redis / Upstash):
  • {keyPrefix}records:{sessionId}:{taskId} — the TaskRecord (auto-expires at ttl).
  • Pub/sub channel {keyPrefix}terminal:{taskId} — fires on terminal transitions; used by tasks/result waiters.
  • Pub/sub channel {keyPrefix}cancel:{taskId} — fires when tasks/cancel lands on a different node than the executor.

SQLite example

Schema (from SqliteTaskStore):
Pub/sub on SQLite is a Node EventEmitter scoped to the process that opened the file — not a SQLite feature. In practice:
  • When the same process creates the task, runs the worker, and reads the result, tasks/result unblocks via the EventEmitter immediately. ✅
  • When a different process reads the same database (a later CLI invocation, a sibling host), there’s no cross-process notification — that caller’s tasks/result falls back to polling the record via tasks/get. The built-in re-check after subscribeTerminal catches the terminal state on the next tick.
For truly cross-process blocking across a fleet, use Redis/Upstash.
Vercel KV is not supported for the task store — it lacks pub/sub, which is required for cross-node cancel signalling and for tasks/result to unblock on a different node than the one that finished the work.

@FrontMcp configuration reference

Error reference

All errors are thrown from the flow layer and translated to MCP SDK McpError instances in the transport handlers — consumers see the exact codes above on the wire.

Security

  • Session binding — task records are keyed by sessionId. A tasks/get/result/cancel from a different session returns -32602 with the same message as an unknown taskId, so attackers cannot enumerate valid task IDs via disambiguating errors.
  • Authorities enforced before task creation — task-augmented tools/call runs the tool’s authorities check synchronously, BEFORE any CreateTaskResult is returned. Unauthorized callers never see a taskId and no record is written. The createTaskIfRequested stage sits between checkEntryAuthorities and createToolCallContext in the tools:call-tool flow plan.
  • Cryptographic task IDs — generated via randomUUID() from @frontmcp/utils (≥ 122 bits of entropy). Guessing another session’s taskId is not a viable attack even before the session binding check.
  • SQLite store encryption — when tasks.sqlite.encryption.secret is configured, the record_json column is encrypted at rest with AES-256-GCM (indexed columns stay plaintext so list/get queries remain fast).
  • TTL discipline — clamp client-requested ttl via maxTtlMs so a misbehaving client can’t pin resources indefinitely. Zero / negative TTL values are rejected at the schema layer.
  • Rate limiting — use FrontMCP’s existing tool-level rateLimit config to throttle task creation if untrusted clients can reach the tool.
All of the above are exercised by the apps/e2e/demo-e2e-security smoke suite — 24 tests covering every MCP auth boundary that FrontMCP enforces:
  • anonymous / malformed / expired / wrong-issuer JWT rejection at the transport,
  • tool-level RBAC and input-bound ABAC on tools/call + tools/list filtering,
  • resource-level authorities on resources/read + resources/list filtering,
  • prompt-level authorities on prompts/get + prompts/list filtering,
  • synchronous task-auth denial (no taskId handed back to unauthorized callers),
  • cross-session task access — tasks/get/result/cancel/list all refuse another session’s taskId with uniform -32602 error messages so attackers can’t enumerate valid IDs,
  • elicitation cross-session isolation — a second session posting elicitation/result for a victim’s pending elicit cannot hijack the response.
Run it locally with yarn nx test:e2e demo-e2e-security — a failure there means an auth boundary has regressed.

Runtime support matrix

FrontMCP ships two runners for task execution: an in-process runner (default, for long-lived servers) and a CLI runner that spawns detached child processes backed by a shared SQLite database. Pick whichever matches the process lifecycle guarantees of your target.

Why the CLI runner exists

Serverless-style short-lived hosts have the same fundamental problem: when the HTTP response flushes, the process freezes, and an in-process Promise the task runner scheduled never resumes. For CLI-style hosts (typically a stdio MCP endpoint or a one-shot HTTP handler) the fix is different from serverless: we can fork a detached OS process that survives the parent, runs the tool, writes its terminal outcome to SQLite, and exits.

Enabling the CLI runner

What the runner takes care of automatically:
  • PID tracking — every task record gets executor.pid stamped on the store so cancellation knows which OS process to signal.
  • SIGTERM on tasks/cancel — the worker’s existing AbortController plumbing (this.signal on ToolContext) fires exactly as for an in-process task, so the same tool code works for both runners.
  • Orphan detection — every tasks/get or tasks/list read probes process.kill(pid, 0) on any non-terminal record. A dead PID transitions the record to failed with statusMessage: 'Task runner exited before completing the task'.
  • Persistence across invocations — SQLite is a single file; any FrontMCP process that opens the same path (future CLI invocations, a sibling HTTP server, etc.) sees the same tasks.

Serverless / edge

Serverless and edge runtimes are still not supported for task execution — the in-process runner can’t survive a frozen isolate, and most sandboxes won’t let you spawn a detached child process either. FrontMCP handles these cleanly:
  • Warn (default) — log a startup warning, accept task-augmented requests, never execute them. Unhealthy silence.
  • Throw (tasks.strict: true) — refuse to start when the runtime can’t run tasks. Recommended for production.
Mitigation: move long-running work to a dedicated queue/worker (SQS, Cloudflare Queues, BullMQ) and expose only the “collect result” tools through FrontMCP.

Limitations (current iteration)

  • Server-side receiver only. Task augmentation of server→client requests (sampling/createMessage, elicitation/create) is not yet implemented.
  • CLI runner: single-host SQLite. Detached workers are spawned on the same machine as the host. Cross-machine task dispatch requires Redis (and a traditional long-lived host, not the CLI runner).
  • Serverless/edge not supported. See runtime support matrix. No generic waitUntil() integration yet.
  • No queue backend. Tasks execute in a flow pipeline — either in-process or one-process-per-task. For high fan-out, pair with the concurrency and rateLimit tool metadata; a proper queue integration is a separate follow-up.
  • Elicitation — the input_required task status integrates with the existing elicitation flow.
  • Observability — each task flow stage is instrumented; traces surface tasks:get, tasks:result, tasks:cancel, tasks:list spans.
  • Testing frameworkmcp.raw.request(...) in @frontmcp/testing lets you drive the full tasks/* protocol from tests. See apps/e2e/demo-e2e-tasks for the HTTP + in-process runner suite, and apps/e2e/demo-e2e-cli-tasks for the CLI runner suite (detached workers, SIGTERM cancellation, SIGKILL orphan detection, SQLite persistence).