Skip to main content

Tasks

Running a cell can take ten minutes. A client that holds a connection open for those ten minutes loses the work when the laptop sleeps, and one that gives up waiting has no way to find out what happened.

A task is the protocol's answer. tools/call returns a task id immediately, the work goes on without the caller, and the client asks for the result whenever it likes — from another connection, after a reconnect, tomorrow.

Nothing changes unless you ask​

A task is created only when the request carries task metadata. Without it, tools/call runs exactly as it always has and returns the tool's output.

{
"method": "tools/call",
"params": {
"name": "execute_cell",
"arguments": { "cell_index": 3 },
"task": { "ttl": 900000 }
}
}

The answer is a CreateTaskResult rather than the tool's output:

{
"task": {
"taskId": "tsk_9f2c…",
"status": "working",
"createdAt": "2026-08-28T10:00:00Z",
"lastUpdatedAt": "2026-08-28T10:00:00Z",
"ttl": 900000,
"pollInterval": 1000
}
}

This is why the server never decides for you. A client that has not asked for a task does not know what a task id is, and would read that object as the tool's output.

A retry is one task​

Name the call with an idempotency key and a retry answers the task the first attempt created, rather than starting the work again:

{
"method": "tools/call",
"params": {
"name": "execute_cell",
"arguments": { "cell_index": 3 },
"task": {},
"_meta": { "io.datalayer/idempotency-key": "run-3-attempt-1" }
}
}

This matters most exactly when it is hardest to notice: the connection drops after the request arrived and before the answer got back. Without a key the client retries and gets two ten-minute cells, and pays for both.

The same key on a different call is refused rather than replayed. Answering the first task would hand the client the result of work it did not ask for, under an id it believes it just created. The same call written with its arguments in another order is still the same call.

A key belongs to a task, so it lives as long as the task does: once a task has expired its key is free again. And a call that did not ask for a task is not deduplicated — a synchronous call carrying a key is still a synchronous call.

The five states​

StatusMeaning
workingRunning now
input_requiredWaiting on something only a person can give
completedDone; ask tasks/result for the output
failedStopped on an error; tasks/result raises it
cancelledStopped because somebody asked

The last three are terminal: the record stops changing.

The four methods​

MethodAnswers
tasks/getOne task's status
tasks/listThe tasks this server holds, newest first
tasks/cancelStops the work and answers the task
tasks/resultThe output, once the task is terminal

There is no tasks/update. A client does not change a task; the server tells it through notifications/tasks/status.

A failure is a failed task, not an empty one​

A tool that raised ends failed, carrying the error, and tasks/result raises it. tasks/result on a task that is still working is refused too.

Both refusals exist for the same reason: the alternative is answering with an empty result, and a client that reads an empty result as "the tool produced nothing" is wrong in a way it cannot detect.

Cancelling stops the work​

tasks/cancel does two things, in this order: it interrupts the work, then it stops waiting for it.

The order matters, and so does the first half. Cancelling the coroutine that is waiting for a cell does not stop the cell — the kernel keeps running it, keeps holding the sandbox and keeps costing money, while the task says cancelled and everybody believes it stopped.

A tool that can actually stop its work says so:

from jupyter_mcp_server.tasks import register_interrupt

await register_interrupt(kernel.interrupt)

Outside a task this answers False and does nothing, which is right: a synchronous call has the client on the other end of the connection, and the client can drop it.

Every loop that waits for a cell registers it — execute_cell in both its streaming and non-streaming forms, and execute_code — so cancelling a task that is running a cell stops the cell whichever tool started it.

That is worth stating because it was not true. execute_cell(stream=True) runs a monitor loop of its own, and it is the documented mode for long-running cells, so it is the one most likely to be cancelled: it had neither hook while the other loop had both. A test now finds the loops by shape rather than by name, so a fourth is covered on the day it is written.

A cancelled task keeps what it produced​

result arrives whole when the tool returns. That is the right shape for a call that finishes and no shape at all for one that does not: cancel a ten-minute cell at minute nine and the task is cancelled with no result, though the cell printed five hundred lines — and those lines are exactly what the person who cancelled it wanted to read.

So a tool that produces output as it goes says so:

from jupyter_mcp_server.tasks import record_output

await record_output(outputs_so_far)

It replaces rather than appends: the caller holds the whole list of outputs so far, and appending would make every reader deduplicate what it reads. Outside a task it answers False and does nothing, for the same reason register_interrupt does.

On cancellation the partial output becomes the result — the task is terminal, so tasks/result serves it. A task that produced nothing keeps no result: an empty list there would read as a measured zero rather than as nothing to measure, which is the distinction the two refusals above exist for.

A tool's own result always wins. If the call returned while the cancellation was landing, the complete answer is not replaced by the half that was visible a moment earlier.

A failed task keeps its partial output too, though tasks/result raises for it: a cell that printed for nine minutes and then raised has the output somebody needs in order to see why, and discarding it at the moment of failure discards the diagnosis.

An interrupt that fails does not stop the cancellation. Half of what the client asked for is better than none of it, and the failure is logged at error — a kernel that could not be interrupted is a sandbox somebody has to go and look at.

Retention​

A finished task is kept for its ttl — 15 minutes by default, capped at 24 hours — and is then gone. Asking for an expired task answers no such task, the same answer as for one that never existed. That is deliberate: distinguishing the two would tell a caller that an id they invented happens to have existed.

A task that is still running never expires. Retention that killed work in flight would be a timeout wearing retention's name, and the two are set by different people for different reasons.

Polling, and the notification​

Every working task carries a pollInterval in milliseconds. Polling is the way a client learns a task's state; notifications/tasks/status is an optimisation on top, sent when a task reaches a terminal state.

Treat the notification as advisory. A session that cannot carry it costs latency and nothing else — the task still completes, and the next poll finds it. The notification carries the status and never the result: a result can be a megabyte of output, and tasks/result is where the client decides whether it wants it.

Where tasks live​

By default, in the server process — which for a single-user server is where its tasks belong. JUPYTER_MCP_TASK_STORE_CLASS names another as module:Class:

export JUPYTER_MCP_TASK_STORE_CLASS=my_package.stores:RedisTaskStore

A store that cannot be imported is a startup failure, not a fallback to memory. A deployment that asked for durable tasks and silently got in-process ones looks healthy right up to the restart that loses them.

A store implements five methods:

class TaskStore(Protocol):
async def create(self, record: TaskRecord) -> TaskRecord: ...
async def get(self, task_id: str) -> TaskRecord | None: ...
async def list(self, *, limit: int = 50) -> list[TaskRecord]: ...
async def update(self, task_id: str, **changes) -> TaskRecord | None: ...
async def find_by_key(self, idempotency_key: str) -> TaskRecord | None: ...

get and find_by_key are both responsible for retention: a task past its ttl is answered as absent by each, and freeing the key is what lets a client reuse it.

An extension, for now​

mcp.types already defines every shape on this page — Task, TaskStatus, CreateTaskResult, the tasks/* requests. What the SDK does not yet do is route the methods, so the server binds them as an extension advertised under io.datalayer/tasks.

That identifier is one constant. When the SDK routes tasks/* itself, the binding is removed and nothing else about a task changes — and until then the binding will refuse to construct the moment the SDK claims those methods, which is the loud way to find out.