mcp-longjobs
# mcp-longjobs
**Durable, resumable operations for MCP — long-running tasks and large files that survive timeouts, disconnects, and client restarts. On every client, today.**
[](https://github.com/ljppanda/mcp-longjobs/actions/workflows/ci.yml)
[](https://www.npmjs.com/package/mcp-longjobs)
[](./LICENSE)
[中文文档](./README.zh-CN.md)
## The problem
Three things break every MCP server that does real work:
- **Long-running tool calls time out.** Clients impose per-call timeouts (often 10–60s). A crawl, a build, a batch job fails — and the model's "retry" restarts the whole operation from scratch.
- **Failures are unrepairable.** A failed call returns a freeform error, so the model guesses: retry blindly, or give up. It can't fix one parameter and resume.
- **Large files have no transfer story.** Binary content is base64-in-JSON (33% overhead, hard message-size caps) or a bare URL with zero conventions — no chunking, no resume, no integrity checks.
The [2026-07-28 MCP spec](https://modelcontextprotocol.io/specification/2026-07-28) added [Tasks](https://modelcontextprotocol.io/extensions/tasks/overview) — async execution with mid-flight input and durable handles. But [no client supports it yet](https://modelcontextprotocol.io/extensions/client-matrix), and the spec requires servers to refuse tasks for clients that didn't opt in. **Every long-running server therefore needs a fallback path that works on today's clients. That is this package.**
## What you get
```ts
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { JsonFileSessionStore, withTasks, withFileTransfer, asToolRegistrar } from "mcp-longjobs";
const mcp = new McpServer({ name: "my-server", version: "1.0.0" });
const registrar = asToolRegistrar(mcp);
const store = new JsonFileSessionStore("./state/sessions.json");
const tasks = withTasks(registrar, { store });
tasks.taskTool("crawl-site", {
description: "Crawl a site and produce a report (takes minutes)",
inputSchema: { url: z.string(), maxPages: z.number().default(50) },
}, async (args, ctx) => {
for (const page of pages) {
if (ctx.signal.aborted) throw new Error("cancelled");
await ctx.progress(`Crawled ${page.url}`, done / total);
if (needsConfirmation(page)) {
const answer = await ctx.needInput({ prompt: `Include ${page.url}?`, choices: ["yes", "no"] });
if (answer === "no") continue;
}
}
return { summary, reportPath }; // small result for the model; big artifacts go through file transfer
});
withFileTransfer(registrar, { store, storageDir: "./state/blobs" });
```
What the model experiences on **today's clients** (no Tasks support required):
1. `crawl-site` returns **instantly** with a `taskId` and instructions to poll `durable_task_get` — no more timeouts.
2. Polls show live progress: `{ "status": "working", "progress": { "message": "Crawled /pricing", "fraction": 0.4 } }`.
3. Mid-flight questions pause the task as `input_required`; the model answers via `durable_task_respond` and the task continues where it stopped.
4. **Client crash? New session?** `durable_task_get` with the same `taskId` still works — state lives in the store, not in the connection.
5. `durable_task_cancel` aborts the work cooperatively at its next checkpoint.
Failures are **data, not protocol errors** — a structured envelope the model can repair in one round-trip:
```json
{
"status": "failed",
"error": {
"code": "offset_mismatch",
"message": "Expected offset 131072, got 0.",
"retryable": true,
"recoveryHint": "Do NOT resend the whole file. Re-send this chunk starting at offset 131072.",
"partial": { "cursor": 131072 }
}
}
```
## Packages (subpath exports)
| Import | Purpose |
| --- | --- |
| `mcp-longjobs/tasks` | `withTasks()` + the `durable_task_*` facade: background execution, progress, mid-flight input, cooperative cancellation |
| `mcp-longjobs/files` | `withFileTransfer()`: chunked upload/download, resume cursor, sha256 verification, path-safety roots |
| `mcp-longjobs/core` | Session model, pluggable stores (memory, JSON file), structured error envelope |
## Design notes
- **Bytes never flow through the model.** The model sees metadata only: handle, size, sha256, progress. Chunks through tool calls are for small-to-medium payloads; large files should move out-of-band (TUS endpoint planned) with the model verifying integrity.
- **The model is the director, not the courier.** Facade tool results carry their own instructions ("call `durable_task_get` with this id", "resume at offset N"), so any capable model can drive the protocol with zero host-side support.
- **Failures are repairable data.** Every failure carries `code`, `retryable`, `recoveryHint`, and `partial.cursor` — what went wrong, whether a retry can work, what to do instead, and what already succeeded.
- **Lifecycle vocabulary matches the spec.** `working / input_required / completed / failed / cancelled`, so the native adapter can slot in later without breaking changes.
## Status
| Component | Status |
| --- | --- |
| Tasks fallback facade (progress / input / cancel) | ✅ implemented |
| Durable session stores (memory, JSON file, SQLite) | ✅ implemented |
| Session TTL expiry (lazy, repairable envelope) | ✅ implemented |
| Chunked file transfer with resume + checksums | ✅ implemented |
| Native ext-tasks adapter (`CreateTaskResult` / `tasks/get`) | 🔜 tracks the SDK's experimental Tasks API |
| TUS 1.0 out-of-band endpoint for large files | 🔜 planned — see [mcp#189](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/189) |
| Redis store, Python port | 🔜 planned |
## Quickstart
```bash
git clone https://github.com/ljppanda/mcp-longjobs
cd mcp-longjobs
npm install && npm run build
node dist/examples/report-generator.js
```
(Once published to npm, the same server runs with a single command: `npx mcp-longjobs`.)
Point your client at it (stdio):
```json
{
"mcpServers": {
"report-generator": {
"command": "node",
"args": ["/absolute/path/to/mcp-longjobs/dist/examples/report-generator.js"]
}
}
}
```
Then ask: *"Generate a report on EV batteries with 3 sections."* Watch the model start the job, poll `durable_task_get`, and pick up the result. Kill the client mid-run, restart it, and ask for the same taskId — it resumes.
## Development
```bash
npm install
npm test # vitest
npm run build # tsc -> dist/
npm run example # build + run the demo server
```
## Contributing
PRs welcome — especially: store backends (SQLite/Redis), the native ext-tasks adapter, and the TUS endpoint. Please open an issue first for anything larger.
## License
[MIT](./LICENSE)
TDQS
Scored across 9 tools
Each tool maps to a distinct action within its domain: durable_task_get/respond/cancel are clearly separate task lifecycle operations, and file_transfer_open/write/read/commit/status cover distinct phases of a transfer. generate-report is the only job-creation tool and does not overlap with the polling or transfer tools.
Most tools follow a clear prefix_verb snake_case pattern, such as durable_task_get and file_transfer_open, but generate-report breaks this by using a hyphenated verb-noun style. The group prefixes help readability, but the mixed conventions prevent a higher score.
Nine tools is well within the ideal range, and each appears to serve a specific necessity for either durable task coordination or chunked file transfer. The count feels focused and purposeful rather than bloated.
The task lifecycle is well covered with get, respond, and cancel, and the file transfer flow covers open, read/write, commit, and status. Minor gaps exist: there is no explicit file transfer cancellation/abort tool, and task creation is only demonstrated through generate-report rather than a generic create tool.