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 aDocumentation Index
Fetch the complete documentation index at: https://docs.agentfront.dev/llms.txt
Use this file to discover all available pages before exploring further.
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/statusupdates on state transitions - Signal cancellation mid-flight with the standard
tasks/cancelRPC
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:
| Value | Semantics | Default |
|---|---|---|
'forbidden' | Task-augmented calls rejected with -32601 (spec-mandated default) | ✓ |
'optional' | Client MAY augment — synchronous calls still work | |
'required' | Synchronous calls rejected with -32601 — task invocation is mandatory |
2. Enable the task subsystem
main.ts
tasks capability is advertised to clients during initialize:
3. Invoke from the client
Lifecycle
- Tasks begin in
working. - From
workingthey may move toinput_required,completed,failed, orcancelled. - From
input_requiredthey may move back toworking, or to a terminal state. - Terminal is final — a task that becomes
cancelledstayscancelledeven if the underlying code keeps running.
{ 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
- The task record transitions to
cancelledbefore thetasks/cancelresponse is sent. - The runner’s cancellation hook fires, routing through one of three mechanisms depending on how the task is executing:
- In-process runner —
AbortController.abort()on the controller tracked in theTaskRegistryfor the taskId. Tools observe it viathis.signalonToolContext. - 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 localAbortController. - CLI runner —
process.kill(executor.pid, 'SIGTERM')sent to the detached worker. The worker’s ownSIGTERMhandler callsAbortController.abort()sothis.signalfires in the tool exactly as it would for in-process execution.
- In-process runner —
- A second
tasks/cancelon a terminal task returns-32602(Invalid params) per spec — this is checked inTasksCancelFlowbefore any runner work happens.
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.
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
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 pushnotifications/tasks/status when a task’s state changes. The message carries the full task wire shape:
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:| Backend | Config field | Pub/sub mechanism | Use when… |
|---|---|---|---|
| Memory | (default, no config) | In-process EventEmitter | Single-node dev / test. Tasks are ephemeral — a server restart wipes them. |
| Redis / Upstash | tasks.redis: {...} | Native PUBLISH/SUBSCRIBE | Multi-node deployments that need tasks/cancel and blocking tasks/result to route to whichever node is running the task. |
| SQLite | tasks.sqlite: { path } | In-process EventEmitter (same-process only) | Single-host persistence across invocations. Required for runner: 'cli' — the detached worker and the host share a database file. |
Redis / Upstash example
{keyPrefix}records:{sessionId}:{taskId}— the TaskRecord (auto-expires atttl).- Pub/sub channel
{keyPrefix}terminal:{taskId}— fires on terminal transitions; used bytasks/resultwaiters. - Pub/sub channel
{keyPrefix}cancel:{taskId}— fires whentasks/cancellands on a different node than the executor.
SQLite example
SqliteTaskStore):
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/resultunblocks 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/resultfalls back to polling the record viatasks/get. The built-in re-check aftersubscribeTerminalcatches the terminal state on the next tick.
@FrontMcp configuration reference
Error reference
| Scenario | JSON-RPC code | Error name |
|---|---|---|
tools/call with task field on a tool with taskSupport: 'forbidden' or unset | -32601 | TaskAugmentationNotSupportedError |
tools/call without task field on a tool with taskSupport: 'required' | -32601 | TaskAugmentationRequiredError |
tasks/get / tasks/result / tasks/cancel with unknown or foreign taskId | -32602 | TaskNotFoundError |
tasks/cancel on a task already in a terminal state | -32602 | TaskAlreadyTerminalError |
tasks subsystem not initialized (misconfiguration) | -32603 | TaskStoreNotInitializedError |
McpError instances in the transport handlers — consumers see the exact codes above on the wire.
Security
- Session binding — task records are keyed by
sessionId. Atasks/get/result/cancelfrom a different session returns-32602with 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/callruns the tool’sauthoritiescheck synchronously, BEFORE anyCreateTaskResultis returned. Unauthorized callers never see a taskId and no record is written. ThecreateTaskIfRequestedstage sits betweencheckEntryAuthoritiesandcreateToolCallContextin thetools:call-toolflow 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.secretis configured, therecord_jsoncolumn is encrypted at rest with AES-256-GCM (indexed columns stay plaintext solist/getqueries remain fast). - TTL discipline — clamp client-requested
ttlviamaxTtlMsso 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
rateLimitconfig to throttle task creation if untrusted clients can reach the tool.
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/listfiltering, - resource-level authorities on
resources/read+resources/listfiltering, - prompt-level authorities on
prompts/get+prompts/listfiltering, - synchronous task-auth denial (no taskId handed back to unauthorized callers),
- cross-session task access —
tasks/get/result/cancel/listall refuse another session’s taskId with uniform-32602error messages so attackers can’t enumerate valid IDs, - elicitation cross-session isolation — a second session posting
elicitation/resultfor a victim’s pending elicit cannot hijack the response.
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.| Runtime | Status | Runner | Notes |
|---|---|---|---|
| Node.js (streamable-http server, Bun, Deno) | ✅ Fully supported | in-process (default) | Primary target. Covered by demo-e2e-tasks. |
| Node.js (stdio) | ✅ Supported | in-process or cli | For stdio hosts that want tasks to survive session disconnect, switch to runner: 'cli' + tasks.sqlite. |
| CLI hosts (short-lived MCP endpoints) | ✅ Fully supported | cli | Each task runs in a detached worker process that writes its outcome to SQLite. tasks/cancel sends SIGTERM to the worker. Covered by demo-e2e-cli-tasks. |
| Browser bundle | ✅ Supported | in-process (memory) | SQLite and detached spawn aren’t available in the browser. Use the default in-process runner with the memory store. |
| Serverless (AWS Lambda, Vercel Node functions) | ❌ Not supported | — | The in-process runner is killed when the Lambda returns; the cli runner can’t spawn detached processes from most serverless sandboxes. Set tasks.strict: true to fail startup instead of silently accepting tasks that will never run. Move long work to a queue worker. |
| Edge runtime (Vercel Edge, Cloudflare Workers) | ❌ Not supported | — | No spawn; no long-lived runtime. FrontMCP logs a warning at startup (throws when tasks.strict: true). |
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-processPromise 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
- PID tracking — every task record gets
executor.pidstamped on the store so cancellation knows which OS process to signal. - SIGTERM on
tasks/cancel— the worker’s existingAbortControllerplumbing (this.signalonToolContext) fires exactly as for an in-process task, so the same tool code works for both runners. - Orphan detection — every
tasks/getortasks/listread probesprocess.kill(pid, 0)on any non-terminal record. A dead PID transitions the record tofailedwithstatusMessage: '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.
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
concurrencyandrateLimittool metadata; a proper queue integration is a separate follow-up.
Related
- Elicitation — the
input_requiredtask status integrates with the existing elicitation flow. - Observability — each task flow stage is instrumented; traces surface
tasks:get,tasks:result,tasks:cancel,tasks:listspans. - Testing framework —
mcp.raw.request(...)in@frontmcp/testinglets you drive the fulltasks/*protocol from tests. Seeapps/e2e/demo-e2e-tasksfor the HTTP + in-process runner suite, andapps/e2e/demo-e2e-cli-tasksfor the CLI runner suite (detached workers, SIGTERM cancellation, SIGKILL orphan detection, SQLite persistence).