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ā
| Status | Meaning |
|---|---|
working | Running now |
input_required | Waiting on something only a person can give |
completed | Done; ask tasks/result for the output |
failed | Stopped on an error; tasks/result raises it |
cancelled | Stopped because somebody asked |
The last three are terminal: the record stops changing.
The four methodsā
| Method | Answers |
|---|---|
tasks/get | One task's status |
tasks/list | The tasks this server holds, newest first |
tasks/cancel | Stops the work and answers the task |
tasks/result | The 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.