Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/website/content/docs/core-concepts/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"tools",
"prompts",
"resources",
"tasks",
"middlewares",
"css",
"external-clients"
Expand Down
183 changes: 183 additions & 0 deletions apps/website/content/docs/core-concepts/tasks.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
---
title: "Tasks"
metadataTitle: "Tasks | xmcp Documentation"
publishedAt: "2026-06-10"
summary: "Learn how to run tools as long-running, pollable tasks in your xmcp application."
description: "Tasks let a tool call run as a durable, pollable operation. The call returns immediately with a task id, and clients poll for status and fetch the result later. xmcp keeps this fully stateless by storing task state in an external store you provide."
---

`xmcp` enables tasks when you add a `src/task-store.ts` file. A task lets a tool call run as a long-running, pollable operation, following the [MCP tasks utility](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks). Instead of blocking until the tool finishes, the server returns a task immediately. Clients poll `tasks/get` for status and call `tasks/result` for the output once the task reaches a terminal state.

Tasks are stateless. The HTTP transport builds a fresh server per request and never holds task state in memory. All state lives in a task store that you back with external storage such as Redis, a database, or a KV service. This makes tasks a good fit for serverless.

<Callout variant="info">
Tasks are experimental in the MCP specification and may evolve in future
protocol versions.
</Callout>

## The task store

The task store is the single source of truth. Default-export one from `src/task-store.ts`. Its presence enables tasks and advertises the `tasks` capability during initialization.

`createTaskStore` builds a complete store from four persistence methods. It handles id generation, timestamps, the status lifecycle, TTL expiry, and pagination for you:

```ts title="src/task-store.ts"
import { promises as fs } from "node:fs";
import path from "node:path";
import { createTaskStore, type TaskRecord } from "xmcp";

const dir = path.resolve(process.cwd(), ".tasks");
const file = (taskId: string) => path.join(dir, `${taskId}.json`);

export const store = createTaskStore({
async get(taskId) {
try {
return JSON.parse(await fs.readFile(file(taskId), "utf-8")) as TaskRecord;
} catch {
return null;
}
},
async set(taskId, record) {
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(file(taskId), JSON.stringify(record));
},
async delete(taskId) {
await fs.rm(file(taskId), { force: true });
},
async list() {
const names = (await fs.readdir(dir)).filter((n) => n.endsWith(".json"));
return Promise.all(
names.map((n) =>
fs.readFile(path.join(dir, n), "utf-8").then((r) => JSON.parse(r))
)
);
},
});

export default store;
```

The adapter only stores and loads a `TaskRecord` (a JSON object) by id. `createTaskStore` accepts optional tuning as a second argument:

**`defaultTtlMs`**

- Lifetime in ms when the requestor doesn't ask for one
- Defaults to `null` (unlimited)

**`pollIntervalMs`**

- Suggested client poll interval, surfaced in task responses
- Defaults to `1000`

**`pageSize`**

- Page size for `tasks/list`
- Defaults to `50`

## Marking a tool as a task

A tool opts in with `taskSupport` in its `metadata`:

```ts title="src/tools/long-job.ts"
import { z } from "zod";
import { type InferSchema, type ToolExtraArguments, type ToolMetadata } from "xmcp";
import { store } from "../task-store";

export const schema = {
label: z.string(),
};

export const metadata: ToolMetadata = {
name: "long_job",
description: "Runs a long job as a task.",
taskSupport: "required",
};

export default async function longJob(
{ label }: InferSchema<typeof schema>,
extra: ToolExtraArguments
) {
const taskId = extra.task?.taskId;

// Kick off the work out-of-band and return nothing: the task stays "working"
// until your worker writes the result back through the store.
void enqueue({ taskId, label });
}
```

`taskSupport` accepts:

- **`"forbidden"`** (default): the tool is never task-augmented.
- **`"optional"`**: clients may call it as a task or as a normal call.
- **`"required"`**: clients must call it as a task.

When a tool runs as a task, `extra.task.taskId` is available. Use it to key the work you enqueue and to write the result back to your store.

## Execution

xmcp creates the task and serves status and result reads. It does not run the background work. You decide how the tool executes:

- **Return a result synchronously.** If your handler returns a `CallToolResult` (or a string or number), xmcp stores it as the terminal result immediately. This is good for quick work or long-lived Node servers.
- **Complete it out-of-band.** Return nothing to leave the task `working`, enqueue the work (queue, cron, worker), and have that worker write the result back through the store. This is the right model for serverless, where the function may freeze after returning.

<Callout variant="info">
If a tool is `"optional"` and a client calls it **without** a task, it is
expected to return a normal result synchronously. Use `"required"` for work
that only ever completes out-of-band.
</Callout>

### Returning a string from a worker

An out-of-band worker can store a plain `string` or `number`. xmcp coerces it into a `CallToolResult` when the client reads it back, exactly like returning a string from a normal tool:

```ts title="src/jobs/worker.ts"
await store.storeTaskResult(taskId, "completed", `Job "${label}" finished.`);
// the client receives: { content: [{ type: "text", text: "Job \"...\" finished." }] }
```

If you store an object instead, it must already be a valid `CallToolResult` (for example `{ content: [{ type: "text", text: "..." }] }` or `{ structuredContent: {...} }`). Objects are returned to the client unchanged.

## Using another store

The adapter is just `get` / `set` / `delete` / `list` over a JSON record, so any KV or database fits. Point the four methods at it:

```ts title="src/task-store.ts"
import { createTaskStore } from "xmcp";
import { redis } from "./redis";

export const store = createTaskStore({
get: (taskId) => redis.get(`task:${taskId}`),
set: (taskId, record) => redis.set(`task:${taskId}`, record),
delete: (taskId) => redis.del(`task:${taskId}`),
list: () => redis.mget(`task:*`),
});

export default store;
```

<Callout variant="info">
`list()` only powers `tasks/list`. `createTaskStore` paginates its result in
memory, which is fine for moderate volumes. For very large datasets,
[implement the store yourself](#implementing-the-store-yourself) and paginate
in the query. You can also let your store's native TTL (such as Redis
`EXPIRE`) expire records; `createTaskStore` already evicts expired tasks on
read.
</Callout>

### Implementing the store yourself

`createTaskStore` is a convenience. When you need full control, such as query-level pagination, custom expiry, or multi-tenant keys, implement the `TaskStore` interface directly and default-export it from `src/task-store.ts`. It exposes `createTask`, `getTask`, `storeTaskResult`, `getTaskResult`, `updateTaskStatus`, and `listTasks`. Generate ids with `generateTaskId` (Web Crypto, cryptographically secure), keep a terminal task (`completed`, `failed`, or `cancelled`) terminal, and use ISO 8601 strings for `createdAt` and `lastUpdatedAt`.

## Lifecycle

1. The client calls the tool with a `task` field. xmcp creates a task (`status: "working"`) in your store and returns immediately.
2. The client polls `tasks/get` until the task reaches a terminal status (`completed`, `failed`, or `cancelled`), respecting the `pollInterval`.
3. The client calls `tasks/result` for the underlying tool result. A result with `isError: true` maps to `failed`.
4. `tasks/cancel` moves a non-terminal task to `cancelled`; cancelling a terminal task returns a `-32602` error.
5. After the `ttl` elapses, the store may delete the task.

## Security

Stateless HTTP has no requestor identity, so the task id is the only access control. xmcp generates cryptographically secure ids, and you should prefer short TTLs. If you need stronger isolation between callers, add [authentication](/docs/authentication/jwt).

See the [`tasks-http` example](https://github.com/basementstudio/xmcp/tree/main/examples/tasks-http) for a complete, runnable server.
5 changes: 5 additions & 0 deletions examples/tasks-http/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.vercel
.xmcp
xmcp-env.d.ts
.tasks
.queue
119 changes: 119 additions & 0 deletions examples/tasks-http/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Tasks over HTTP

This example shows how to run a tool as an [MCP task](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks)
in xmcp: a long-running, pollable operation that returns immediately and is
completed **out-of-band, by a separate process**.

xmcp keeps this **stateless**. The HTTP transport builds a fresh server per
request and never holds task state in memory. All task state lives in a
**task store** that you implement against external storage.

## Two processes, one shared store

The whole point of tasks is that the work runs **somewhere other than the
request**. This example makes that boundary explicit with two processes that
share nothing but on-disk state:

```
MCP server (the tool) worker (separate process)
enqueue job ──────► .queue/ ──────► run job
tasks/get / tasks/result ▼
client ◄────────────── .tasks/ ◄── store result
```

1. A tool opts in with `taskSupport: "required"` in its `metadata`
(`src/tools/long-job.ts`). When a client calls it with a `task` field, xmcp
creates a task in the store and returns immediately. **The tool only drops a
job on the queue — it does not run the work.**
2. The worker (`src/worker.ts`) is a standalone process. It polls the queue,
runs the job, and writes the result back through the **same** task store.
3. Clients poll `tasks/get` for status and call `tasks/result` for the output.
They read from the store the worker wrote to — the server that created the
task is long gone.

In production the queue is a real queue (SQS, a database table, Upstash) and the
worker is a queue consumer or cron; the shape is identical.

The task store here writes one JSON file per task under `.tasks/`, and the queue
writes one job file under `.queue/`. Both are dependency-free and fine for
local/single-node use. For serverless or multi-instance deployments, back the
store with Redis, a database, or a KV service.

> The worker logs each job as it runs. That console output is the point of this
> example — it makes execution happening *elsewhere* visible.

## Run it

```bash
pnpm dev
```

`dev` starts **both** processes — the MCP server and the worker — so tasks
complete out of the box. The worker is still a separate process (`tsx watch
src/worker.ts &`); it just shares the terminal.

The server listens on `http://127.0.0.1:3001/mcp`.

> **A task stuck on `working` means the worker isn't running.** The tool only
> enqueues; if nothing drains the queue, the task never completes. `pnpm dev`
> runs the worker for you — or run it yourself with `pnpm worker`.

To see the two processes truly side by side, run them in separate terminals:

```bash
# terminal 1 — server only
pnpm build && pnpm start

# terminal 2 — worker only
pnpm worker
```

## Try the flow

Create a task-augmented call (note the `task` field):

```bash
curl -s http://127.0.0.1:3001/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "long_job", "arguments": { "label": "demo", "seconds": 3 }, "task": { "ttl": 60000 } }
}'
# -> result.task = { taskId, status: "working", ... }
```

Watch terminal 2: the worker prints `▶ running "demo" (3s)…` and then
`✓ completed`. Now poll status and fetch the result:

```bash
curl -s http://127.0.0.1:3001/mcp -H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{ "jsonrpc": "2.0", "id": 2, "method": "tasks/get", "params": { "taskId": "<taskId>" } }'

curl -s http://127.0.0.1:3001/mcp -H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{ "jsonrpc": "2.0", "id": 3, "method": "tasks/result", "params": { "taskId": "<taskId>" } }'
```

Because all state lives in the store, you can kill and restart the server
between `tools/call` and `tasks/get` — the task still resolves. That is
statelessness.

## Notes and caveats

- **The tool never does the work.** It enqueues and returns; a separate process
completes the task and writes the result back through the store. This is what
makes tasks safe on serverless, where the request function may freeze the
moment it returns.
- **`required` vs `optional`.** Use `required` for work that completes
out-of-band. With `optional`, a client that calls the tool *without* a `task`
field expects a normal synchronous result, so an `optional` tool should return
a `CallToolResult` directly in that case.
- **Returning a string.** The worker stores a plain `string`; xmcp coerces it
into a `CallToolResult` when the client reads it back, exactly like returning
a string from a normal tool.
- **Security.** Stateless HTTP has no requestor identity, so the task ID is the
only access control. Use cryptographically secure IDs (see `generateTaskId`)
and short TTLs. Add authentication if you need stronger isolation.
26 changes: 26 additions & 0 deletions examples/tasks-http/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "Tasks HTTP",
"description": "Run long-running tools as MCP tasks over stateless HTTP with an external task store",
"keywords": [
"tasks",
"long-running",
"http",
"stateless"
],
"scripts": {
"build": "xmcp build",
"dev": "tsx watch src/worker.ts & xmcp dev",
"worker": "tsx watch src/worker.ts",
"start": "node dist/http.js",
"type-check": "tsc --noEmit"
},
"dependencies": {
"xmcp": "workspace:*",
"zod": "^4.0.10"
},
"devDependencies": {
"@types/node": "^20.19.26",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}
Loading
Loading