MCP in Practice

Lesson 26 of 36

Tasks II: interruption and cancellation

A long job that cannot ask is a job that fails at minute nine

reflow_month is eight minutes into moving 142 bookings when it hits one it cannot resolve: a recurring Thursday workshop that fits nowhere. Two rooms could take it, and both mean displacing something else.

Failing here wastes eight minutes of work and every decision already made. Guessing produces a plausible schedule that is wrong in a way nobody notices until Thursday. What the task needs is to stop and ask — and it has no connection to ask down, because the client is polling.

The answer is the state you saw last lesson: input_required.

"We found a stain — solvent, or leave it?"

The dry cleaner from Lesson 25 finds something halfway through. They do not throw the coat away, and they do not guess. They put it aside and leave a note on your ticket, and the next time you check in you get the question instead of the coat.

That is input_required exactly. The task is not failed and not finished; it is parked, holding everything it has already done, waiting for one answer. And the mechanism it uses is the one you already know from Lesson 22 — the question rides back on a poll, and the answer arrives in a fresh request.

Asking from inside a task

The poll returns the question instead of a result:

json
{
  "jsonrpc": "2.0",
  "id": 14,
  "result": {
    "resultType": "complete",
    "task": {
      "taskId": "tsk_8f21",
      "status": "input_required",
      "statusMessage": "1 booking could not be placed automatically",
      "inputRequests": {
        "thursday_workshop": {
          "method": "elicitation/create",
          "params": {
            "mode": "form",
            "message": "The Thursday 14:00 workshop (18 people) fits nowhere in the fortnight. What should happen?",
            "requestedSchema": {
              "type": "object",
              "properties": {
                "action": {
                  "type": "string",
                  "enum": ["move-to-atrium", "split-across-two-rooms", "cancel-and-notify"],
                  "description": "How to resolve the clash."
                }
              },
              "required": ["action"]
            }
          }
        }
      }
    }
  }
}

Same elicitation/create shape as Lesson 22. The client answers with tasks/update rather than by retrying the original call, because the original call finished long ago — what is being resumed is the task:

json
{
  "jsonrpc": "2.0",
  "id": 15,
  "method": "tasks/update",
  "params": {
    "taskId": "tsk_8f21",
    "inputResponses": {
      "thursday_workshop": {"action": "accept", "content": {"action": "move-to-atrium"}}
    }
  }
}

The server acknowledges with an empty result, the task returns to working, and polling resumes. Responses for unknown or already-satisfied keys are ignored, which makes a client retrying an update harmless.

Cancellation is cooperative

json
{"jsonrpc": "2.0", "id": 16, "method": "tasks/cancel", "params": {"taskId": "tsk_8f21"}}

The server acknowledges the intent. It is not obliged to stop, and the task may still reach completed or failed instead of cancelled. That is not sloppiness — a task halfway through a database transaction should finish or roll back properly rather than stop where it stands.

The consequence for server authors is the part people miss: your loop must actually look. Nothing interrupts you.

python
for booking in bookings_to_move:
    if await ctx.task_cancelled():
        break
    move(booking)

A task that never checks is a task that cannot be cancelled, whatever the client sends. And check at a point where stopping is safe — between units of work, not mid-write.

The consequence for client authors: tasks/cancel is a request, not a guarantee. Keep polling until a terminal state, and do not report "cancelled" to the user until you see it.

Notifications, and the doorbell you have to install

Polling is the default. A server may also push status updates through notifications/tasks, each carrying the full task state so no extra tasks/get is needed.

But notifications are opt-in, and this is where the current protocol departs sharply from older material. A client that wants them opens a long-lived stream naming the types it wants:

json
{
  "jsonrpc": "2.0",
  "id": 17,
  "method": "subscriptions/listen",
  "params": {"notifications": {"toolsListChanged": true}}
}

The server acknowledges with notifications/subscriptions/acknowledged, listing the subset it agreed to honour, and every notification on that stream carries the subscription id so the client can correlate.

If you have read an older tutorial, notifications simply arrived — a server pushed notifications/tools/list_changed and connected clients received it. Now the client asks first, and a server sends nothing to a client that did not subscribe; the change follows from the same statelessness that reshaped elicitation, since there is no standing channel to push into.

A doorbell is the right picture. Under the old model the server could knock on any door it knew about. Now the client installs the bell and chooses which visitors may ring it — which is better, because a notification nobody asked for is an interruption, and this is the difference between an application that is responsive and one that is noisy.

Notifications are opt-in now: the client opens a stream naming the types it wants
Notifications are opt-in now: the client opens a stream naming the types it wants

One caveat worth honouring: notifications are best effort. They can be lost across a reconnect. Treat them as an optimisation over polling, not a replacement — a client that only reacts to notifications will eventually miss one and wait forever.

What to take into the next lesson

A task that needs an answer moves to input_required and carries the question back on a poll, resumed with tasks/update; cancellation is cooperative, so your loop must check and clients must keep polling to a terminal state; and notifications are opt-in through subscriptions/listen and best-effort, which makes them an optimisation over polling rather than a substitute. Next: an answer that is a picture rather than a sentence.

← Previous