mcp-durable-tasks
v0.2.1
Published
Server-side implementation of the MCP Tasks extension (SEP-2663). Not a to-do manager.
Maintainers
Readme
A durable task state machine for the MCP Tasks extension
(io.modelcontextprotocol/tasks, SEP-2663). Pure TypeScript, with zero
runtime dependencies on its main entry point. Its one job is to hold a
long-running operation's state; with WalTaskStore, state already confirmed by
the application remains available to tasks/get after the connection or the
whole process has gone.
This is a library you import into an MCP server — not a server you add to
mcp.json, and not a to-do manager. "Tasks" here means the extension's durable
task handles: the thing a server returns instead of blocking on work that takes
minutes.
import { TaskLifecycle, MemoryTaskStore } from "mcp-durable-tasks";
const tasks = new TaskLifecycle({ store: new MemoryTaskStore() });
// tools/call decides to defer: hand back a task instead of a result.
const created = await tasks.createTask({ ttlMs: 3_600_000 });
runTheWork(tasks.handle(created.taskId)); // not awaited
// tasks/get, later, possibly from another connection.
const view = await tasks.getTask(created.taskId);The worker writes through the handle:
async function runTheWork(task) {
await task.progress("indexing", { pollIntervalMs: 2_000 });
if (task.signal.aborted) return task.cancelled("client cancelled");
await task.complete({ content: [{ type: "text", text: "done" }] });
}Before you adopt it
WalTaskStore is for one process and one disk. It inherits
process-wal's single-writer
contract: two processes on the same directory corrupt the log. That fits stdio
MCP servers — Claude Code, Cursor, VS Code, a local Codex — which is where the
genuinely long tasks live: builds, test suites, migrations, indexing. For a
stateless HTTP server behind a load balancer, implement TaskStore over your
shared database; it is five methods, and mcp-durable-tasks/testing gives you
the test suite before you write the first one.
Worker coordination has process affinity. TaskStore compare-and-swap
protects the durable record when several instances can write it, but the
promise requestInput() returns and the cancellation signal belong to the
process running that worker. While a worker is live, tasks/update and
tasks/cancel have to reach that instance — sticky routing, or coordination
you provide. This library deliberately does not embed a broker or a queue. The
specification does not define that delivery mechanism either; see
the process-boundary contract.
Contents
- Before you adopt it
- Status
- Install
- Documentation
- Official references
- What survives what
- See it survive a crash
- Known gap in the official SDK
- Writing your own store
- Development
- License
Status
The current release is v0.2.1. It includes the engine, both stores, the public
conformance kit, property-based state-machine tests, real crash tests and the
runnable crash-recovery example. It is published from GitHub Actions through
npm trusted publishing, with provenance tied to the release tag and commit.
v0.1.0 was the one-time manual registry bootstrap. No later release uses a
long-lived npm token.
The public API remains provisional until 1.0.0; the version roadmap and open
extension questions live in
the contract and conformance profile.
Install
Requires Node.js 22 or newer.
The library supports Node.js 22 from its first release; working from source uses the pinned pnpm 11 toolchain and therefore needs Node.js 22.13 or newer.
To install the current registry release:
pnpm add mcp-durable-tasksTo validate the exact tag from source instead:
git clone https://github.com/AndresSaa/mcp-durable-tasks.git
cd mcp-durable-tasks
git checkout v0.2.1
corepack pnpm install --frozen-lockfile
corepack pnpm testprocess-wal is an optional peer dependency, needed only by the /wal
entry point. Nothing else in the package requires it:
pnpm add mcp-durable-tasks process-wal| Entry point | What it holds | Needs |
| --------------------------- | ------------------------------------ | ------------- |
| mcp-durable-tasks | the engine, MemoryTaskStore, types | nothing |
| mcp-durable-tasks/wal | WalTaskStore | process-wal |
| mcp-durable-tasks/testing | the store conformance kit | nothing |
The main entry point uses no Node built-ins, so the engine and
MemoryTaskStore run in web-standard runtimes too.
Documentation
- API — every option, method and error
- Durability — what survives what, and the two unsupported configurations
- Internals — design decisions and source layout
- Contract and conformance — scope, invariants, SDK compatibility findings and open extension questions
Official references
- MCP Tasks extension overview
- Tasks extension draft specification
- SEP-2663 proposal
- Official schema and TypeScript types
- TypeScript SDK v2 migration notes for protocol revision 2026-07-28
What survives what
WalTaskStore writes every mutation to a write-ahead log before returning, and
replays it on open. A returned createTask() means the task is already durable
— the extension states that normatively, and it is the invariant this package
exists for.
Automatic compaction happens only after the triggering mutation is durable. A
compaction failure therefore does not turn that committed mutation into a
rejection. Pass onCompactionError to observe it. The event says whether the
underlying WAL became unusable; in that case the committed call still returns,
and the next store operation fails with ERR_WAL_UNUSABLE so the host can close
and reopen the store for recovery.
WAL entries are bounded, not unbounded. WalTaskStore defaults
maxEntryBytes to 8 MiB per encoded task record (including its envelope
and metadata): enough for roughly 100,000 ordinary 80-byte build-log lines,
while keeping synchronous JSON encoding and snapshot compaction under a hard
per-task ceiling. Set a different limit when your workload warrants it.
An oversized mutation does not commit and throws TaskEntryTooLargeError,
whose stable code is ERR_ENTRY_TOO_LARGE. A worker can therefore preserve
its completed work by retrying the terminal transition with a summary or
truncated result:
import { isTaskEntryTooLargeError } from "mcp-durable-tasks";
try {
await task.complete(fullResult);
} catch (error) {
if (!isTaskEntryTooLargeError(error)) throw error;
await task.complete({ truncated: true, summary: summarize(fullResult) });
}Raising the limit only moves this failure boundary; it never removes it.
Use the predicate rather than instanceof: duplicated npm packages and the
separate CJS entry bundles do not guarantee shared class identity.
Task results, errors, and input payloads are recursively validated as plain JSON
before state changes. Values such as Map, Date, bigint, functions, cycles,
array holes, and non-finite numbers are rejected consistently by both stores;
the in-memory view can therefore never differ from the value replayed from WAL.
JavaScript's -0 is valid JSON, so it is accepted and canonicalised to 0
before mutation; it is the only accepted finite number whose identity changes
through JSON.stringify/JSON.parse.
The input round-trip additionally validates the full MCP shape before a write:
sampling content must be one of its discriminated text, image, audio, tool-use
or tool-result blocks; elicitation schemas stay within MCP's primitive subset;
URL elicitation requires an opaque id and a valid URL; roots use file://;
binary blocks are Base64; and accepted elicitation values are primitives or
string arrays. These rules are pinned to the
@modelcontextprotocol/[email protected] source,
not inferred from the lossy generated extension schema. A JSON object is not
accepted merely because it is serialisable.
| Mode | Process crash, SIGKILL, restart | Host or power loss |
| ------------------------ | --------------------------------- | ------------------------------ |
| fsync: false (default) | Recoverable | Not guaranteed |
| fsync: true | Recoverable | Requests a storage flush first |
The left column is tested, not asserted: test/crash.test.ts runs real child
processes, kills them with SIGKILL at a known state, and reopens the
directory. A task created and never touched again comes back as working; one
parked on input comes back with its requests and its used-key ledger intact, so
the restarted process still refuses to reuse a key the dead one issued; a task
completed the instant before the signal keeps its result; an acknowledged
progress version is a hard lower bound after a kill in later appends; and a
confirmed multi-task snapshot survives a kill in a compaction loop. Those tests
load dist/, so what is proven to recover is the package you install.
The deterministic examples are backed by a fast-check state-machine model: random schedules combine progress, TTL changes and clock rollback, partial and duplicate input responses, cancellation, terminal races and injected CAS conflicts. Every step compares the full record, closed wire projection and single-settlement worker effects against an independent reference state.
See it survive a crash
examples/crash-recovery/ is a real MCP server whose task outlives the
process. One command starts it, defers work through tools/call, kills the
server with SIGKILL, starts it again, and reads the finished result back
from the new process:
pnpm --filter mcp-durable-tasks-example-crash-recovery run demoThe recording above runs the example from a clean checkout. Its essential output is also included below for text-only readers:
3. Kill the server outright — SIGKILL, no shutdown hook, no flush
process gone. Whatever is on disk is all there is.
5. Ask the new process for the task the dead one finished
tasks/get → completed
result → {"content":[{"type":"text","text":"indexed 5 files, 0 errors"}], ...}It runs on every pull request, so it cannot quietly stop being true. The server also shows the simplest verified host workaround — see the next section.
Known gap in the official SDK
On a 2026-07-28 server built with @modelcontextprotocol/server v2, two of
the extension's three methods cannot be served through the SDK's registered
handlers. tasks/get and tasks/cancel answer -32601 before any handler
runs — including
fallbackRequestHandler — because both names belong to the retired
2025-11-25 method registry and the protocol-era gate fires on the way in.
tasks/update works, because SEP-2663 introduced it and no era claims the name.
This is measured, not inferred; the environment and findings are in the
compatibility profile.
The crash-recovery example uses the simplest verified host workaround: answer
those two HTTP requests in middleware before createMcpHandler. Renaming the
methods below Protocol, at the transport seam, is also verified. The SDK bug
is tracked in
typescript-sdk#2598,
with an
upstream fix under review.
Writing your own store
TaskStore is five methods, and you do not have to guess whether yours is
correct — the conformance kit ships with the package:
import { describe, it } from "vitest";
import { runTaskStoreConformance } from "mcp-durable-tasks/testing";
runTaskStoreConformance(
"RedisTaskStore",
() => ({
store: new RedisTaskStore({ url }),
reopen: () => new RedisTaskStore({ url }),
}),
{
runner: {
describe,
it,
skip: (context, reason) =>
(context as { skip(reason?: string): void }).skip(reason),
},
},
);It has no dependencies and no opinion about your test runner; pass describe
and it from whichever you use. If you would rather not involve one at all,
checkTaskStore(name, factory) runs the same checks and hands back a report.
The factory is called once per check and must return a fresh store each time;
the kit rejects a reused instance. Give it an advanceTime(ms) if your store
has a clock seam, and reopen() if a new instance can reconnect to the same
backing data. Without those seams, the TTL or cross-instance durability checks
are reported as skipped rather than quietly passing. Runner integrations must
provide runner.skip(context, reason) to record a real skip; if they do not,
a missing optional seam fails with an actionable message instead of producing
a false green. The reopen path exercises a full partial-input round and proves
that live request/response maps are cleared while usedInputKeys remains
durable across both reopen boundaries.
Every factory, check, close() and dispose() operation has a 10-second
deadline by default (timeoutMs changes it). Cleanup failures are check
failures; they are never swallowed.
TaskPatch follows ordinary object-spread visibility: only enumerable own
string-keyed properties participate. For those properties, undefined means
delete rather than leave unchanged; the kit pins both parts of that rule.
Publish it, open an issue, and it gets linked from these docs.
Development
corepack pnpm install --frozen-lockfile
corepack pnpm lint
corepack pnpm test
corepack pnpm coverage
corepack pnpm lint:package # packs, installs and imports the real tarball
corepack pnpm check:schema # compares the vendored schema against upstreamLicense
MIT

