Skip to content

tasks

SEP-2663 client-side tasks surface.

When a server augments a tools/call into a task — a CreateTaskResult in place of the CallToolResult — this module finishes the flow, twice over. TasksExtension is the transparent path SEP-2663 advises ("existing code returning a fixed shape ... can transparently drive the polling flow internally and surface only the final, completed result"): Client.call_tool polls tasks/get until the task reaches a terminal status and surfaces only the final result. The free functions — get_task, wait_task, update_task, cancel_task — are the manual path, typed over the public ClientSession, for callers that take the CreateTaskResult themselves (via session.call_tool(..., allow_claimed=True)) and drive tasks/* by hand.

The polling loop itself is one pure function (run_task_driver, private) so it stays testable with plain closures; both paths run it.

DEFAULT_POLL_INTERVAL_SECONDS module-attribute

DEFAULT_POLL_INTERVAL_SECONDS = 1.0

Poll cadence when neither the snapshot nor the CreateTaskResult carries pollIntervalMs.

SEP-2663 makes the hint optional and only says clients SHOULD honor it when present; one second is the SDK's conservative default in its absence.

TaskError

Bases: Exception

Base for the typed SEP-2663 task-outcome errors.

A task that ends anywhere other than completed surfaces as one of three subclasses — TaskFailedError, TaskCancelledError, TaskInputRequiredError — so except TaskError handles any non-completion.

Source code in src/mcp/client/tasks.py
64
65
66
67
68
69
70
class TaskError(Exception):
    """Base for the typed SEP-2663 task-outcome errors.

    A task that ends anywhere other than `completed` surfaces as one of three
    subclasses — `TaskFailedError`, `TaskCancelledError`,
    `TaskInputRequiredError` — so `except TaskError` handles any non-completion.
    """

TaskFailedError

Bases: TaskError, MCPError

The task reached failed: a JSON-RPC error occurred during execution (SEP-2663).

Carries the JSON-RPC error inlined on tasks/get as code/message/data, plus the snapshot's optional statusMessage diagnostic.

Source code in src/mcp/client/tasks.py
73
74
75
76
77
78
79
80
81
82
83
84
85
86
class TaskFailedError(TaskError, MCPError):
    """The task reached `failed`: a JSON-RPC error occurred during execution (SEP-2663).

    Carries the JSON-RPC error inlined on `tasks/get` as `code`/`message`/`data`,
    plus the snapshot's optional `statusMessage` diagnostic.
    """

    def __init__(self, error: ErrorData, status_message: str | None = None) -> None:
        super().__init__(code=error.code, message=error.message, data=error.data)
        self.status_message = status_message

    def __reduce__(self) -> tuple[type[TaskFailedError], tuple[ErrorData, str | None]]:
        """Pickle via the constructor args (`args` holds `MCPError`'s, which do not round-trip)."""
        return (type(self), (self.error, self.status_message))

__reduce__

__reduce__() -> (
    tuple[
        type[TaskFailedError], tuple[ErrorData, str | None]
    ]
)

Pickle via the constructor args (args holds MCPError's, which do not round-trip).

Source code in src/mcp/client/tasks.py
84
85
86
def __reduce__(self) -> tuple[type[TaskFailedError], tuple[ErrorData, str | None]]:
    """Pickle via the constructor args (`args` holds `MCPError`'s, which do not round-trip)."""
    return (type(self), (self.error, self.status_message))

TaskCancelledError

Bases: TaskError

The task reached cancelled before producing a result (SEP-2663).

Source code in src/mcp/client/tasks.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
class TaskCancelledError(TaskError):
    """The task reached `cancelled` before producing a result (SEP-2663)."""

    def __init__(self, task_id: str, status_message: str | None = None) -> None:
        detail = f": {status_message}" if status_message is not None else ""
        super().__init__(f"Task {task_id!r} was cancelled{detail}")
        self.task_id = task_id
        self.status_message = status_message

    def __reduce__(self) -> tuple[type[TaskCancelledError], tuple[str, str | None]]:
        """Pickle via the constructor args (`args` holds the formatted message, which does not round-trip)."""
        return (type(self), (self.task_id, self.status_message))

__reduce__

__reduce__() -> (
    tuple[type[TaskCancelledError], tuple[str, str | None]]
)

Pickle via the constructor args (args holds the formatted message, which does not round-trip).

Source code in src/mcp/client/tasks.py
 98
 99
100
def __reduce__(self) -> tuple[type[TaskCancelledError], tuple[str, str | None]]:
    """Pickle via the constructor args (`args` holds the formatted message, which does not round-trip)."""
    return (type(self), (self.task_id, self.status_message))

TaskInputRequiredError

Bases: TaskError

The task reached input_required, which the polling loop does not drive yet.

SEP-2663's in-task input loop (fulfil inputRequests via tasks/update) is a deferred follow-up in this SDK. Drive it manually: fetch the snapshot with get_task and answer its inputRequests with update_task.

Source code in src/mcp/client/tasks.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
class TaskInputRequiredError(TaskError):
    """The task reached `input_required`, which the polling loop does not drive yet.

    SEP-2663's in-task input loop (fulfil `inputRequests` via `tasks/update`) is
    a deferred follow-up in this SDK. Drive it manually: fetch the snapshot with
    `get_task` and answer its `inputRequests` with `update_task`.
    """

    def __init__(self, task_id: str) -> None:
        super().__init__(
            f"Task {task_id!r} requires in-task input (status `input_required`); the SDK's automatic "
            "in-task input loop is not implemented yet. Drive it manually: fetch the snapshot with "
            "`mcp.client.tasks.get_task` and answer with `mcp.client.tasks.update_task`."
        )
        self.task_id = task_id

    def __reduce__(self) -> tuple[type[TaskInputRequiredError], tuple[str]]:
        """Pickle via the constructor args (`args` holds the formatted message, which does not round-trip)."""
        return (type(self), (self.task_id,))

__reduce__

__reduce__() -> (
    tuple[type[TaskInputRequiredError], tuple[str]]
)

Pickle via the constructor args (args holds the formatted message, which does not round-trip).

Source code in src/mcp/client/tasks.py
119
120
121
def __reduce__(self) -> tuple[type[TaskInputRequiredError], tuple[str]]:
    """Pickle via the constructor args (`args` holds the formatted message, which does not round-trip)."""
    return (type(self), (self.task_id,))

run_task_driver async

run_task_driver(
    task_id: str,
    initial_interval_ms: int | None,
    *,
    get_task: Callable[[str], Awaitable[GetTaskResult]],
    sleep: Callable[[float], Awaitable[None]]
) -> CallToolResult

Poll a task to its final CallToolResult (the private engine behind both paths).

Polls tasks/get (via get_task) until the task reaches a terminal status. Between polls it honors the SEP-2663 pollIntervalMs hint: each non-terminal snapshot sleeps its own poll_interval_ms, falling back to initial_interval_ms (the CreateTaskResult's hint, when the caller holds one), then to DEFAULT_POLL_INTERVAL_SECONDS.

The loop deliberately imposes no round cap or deadline of its own: SEP-2663 tasks represent unbounded server-side work, so how long to wait is the caller's policy — cancel via an enclosing anyio cancel scope, or bound each tasks/get round with the session read timeout the get_task closure carries.

Parameters:

Name Type Description Default
task_id str

The task to poll.

required
initial_interval_ms int | None

pollIntervalMs from the CreateTaskResult, or None when the caller holds only a bare task id.

required
get_task Callable[[str], Awaitable[GetTaskResult]]

Sends one tasks/get for the given task id and returns the parsed GetTaskResult snapshot.

required
sleep Callable[[float], Awaitable[None]]

Awaits the given number of seconds between polls (injectable for deterministic tests).

required

Raises:

Type Description
TaskFailedError

The task reached failed; carries the inlined JSON-RPC error.

TaskCancelledError

The task reached cancelled.

TaskInputRequiredError

The task reached input_required (the in-task input loop is not implemented yet).

RuntimeError

The server violated SEP-2663 — a completed snapshot without result, or a failed snapshot without error.

Source code in src/mcp/client/tasks.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
async def run_task_driver(
    task_id: str,
    initial_interval_ms: int | None,
    *,
    get_task: Callable[[str], Awaitable[GetTaskResult]],
    sleep: Callable[[float], Awaitable[None]],
) -> CallToolResult:
    """Poll a task to its final `CallToolResult` (the private engine behind both paths).

    Polls `tasks/get` (via `get_task`) until the task reaches a terminal status.
    Between polls it honors the SEP-2663 `pollIntervalMs` hint: each non-terminal
    snapshot sleeps its own `poll_interval_ms`, falling back to
    `initial_interval_ms` (the `CreateTaskResult`'s hint, when the caller holds
    one), then to `DEFAULT_POLL_INTERVAL_SECONDS`.

    The loop deliberately imposes no round cap or deadline of its own: SEP-2663
    tasks represent unbounded server-side work, so how long to wait is the
    caller's policy — cancel via an enclosing anyio cancel scope, or bound each
    `tasks/get` round with the session read timeout the `get_task` closure
    carries.

    Args:
        task_id: The task to poll.
        initial_interval_ms: `pollIntervalMs` from the `CreateTaskResult`, or
            `None` when the caller holds only a bare task id.
        get_task: Sends one `tasks/get` for the given task id and returns the
            parsed `GetTaskResult` snapshot.
        sleep: Awaits the given number of seconds between polls (injectable for
            deterministic tests).

    Raises:
        TaskFailedError: The task reached `failed`; carries the inlined JSON-RPC error.
        TaskCancelledError: The task reached `cancelled`.
        TaskInputRequiredError: The task reached `input_required` (the in-task
            input loop is not implemented yet).
        RuntimeError: The server violated SEP-2663 — a `completed` snapshot
            without `result`, or a `failed` snapshot without `error`.
    """
    while True:
        snapshot = await get_task(task_id)
        if snapshot.status == "completed":
            if snapshot.result is None:
                raise RuntimeError(f"Task {task_id!r} is `completed` but carries no `result` (SEP-2663 violation)")
            return CallToolResult.model_validate(snapshot.result, by_name=False)
        if snapshot.status == "failed":
            if snapshot.error is None:
                raise RuntimeError(f"Task {task_id!r} is `failed` but carries no `error` (SEP-2663 violation)")
            raise TaskFailedError(ErrorData.model_validate(snapshot.error), snapshot.status_message)
        if snapshot.status == "cancelled":
            raise TaskCancelledError(task_id, snapshot.status_message)
        if snapshot.status == "input_required":
            raise TaskInputRequiredError(task_id)
        interval_ms = snapshot.poll_interval_ms if snapshot.poll_interval_ms is not None else initial_interval_ms
        await sleep(DEFAULT_POLL_INTERVAL_SECONDS if interval_ms is None else max(0, interval_ms) / 1000)

get_task async

get_task(
    session: ClientSession,
    task_id: str,
    *,
    read_timeout_seconds: float | None = None
) -> GetTaskResult

Fetch one SEP-2663 tasks/get snapshot.

One request, one typed parse: the returned GetTaskResult carries result when the task completed, error when it failed, and neither for a non-terminal status.

Parameters:

Name Type Description Default
session ClientSession

The session to send on.

required
task_id str

The task id a CreateTaskResult carried.

required
read_timeout_seconds float | None

Per-request read timeout; defaults to the session's.

None

Raises:

Type Description
MCPError

-32602 (invalid params) for an unknown or expired task id, -32021 (missing required client capability) when this modern client did not declare the extension, or -32601 (method not found) on a legacy (2025-11-25) connection.

Source code in src/mcp/client/tasks.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
async def get_task(
    session: ClientSession,
    task_id: str,
    *,
    read_timeout_seconds: float | None = None,
) -> GetTaskResult:
    """Fetch one SEP-2663 `tasks/get` snapshot.

    One request, one typed parse: the returned `GetTaskResult` carries `result`
    when the task `completed`, `error` when it `failed`, and neither for a
    non-terminal status.

    Args:
        session: The session to send on.
        task_id: The task id a `CreateTaskResult` carried.
        read_timeout_seconds: Per-request read timeout; defaults to the
            session's.

    Raises:
        MCPError: `-32602` (invalid params) for an unknown or expired task id,
            `-32021` (missing required client capability) when this modern
            client did not declare the extension, or `-32601` (method not
            found) on a legacy (2025-11-25) connection.
    """
    request = GetTaskRequest(params=GetTaskRequestParams(task_id=task_id))
    return await session.send_request(request, GetTaskResult, request_read_timeout_seconds=read_timeout_seconds)

wait_task async

wait_task(
    session: ClientSession,
    task: str | CreateTaskResult,
    *,
    read_timeout_seconds: float | None = None
) -> CallToolResult

Poll an SEP-2663 task to a terminal status and return its final CallToolResult.

The manual counterpart of the transparent TasksExtension flow, raising the same typed errors. Pass the CreateTaskResult and its pollIntervalMs hint seeds the polling cadence; pass a bare task id and a client that reconnected — or restarted with only the persisted id — resumes a task it no longer holds the CreateTaskResult for.

The wait itself is unbounded (how long to keep polling is the caller's policy — cancel via an enclosing anyio cancel scope); read_timeout_seconds bounds each tasks/get round, not the whole wait.

Parameters:

Name Type Description Default
session ClientSession

The session to poll on.

required
task str | CreateTaskResult

The CreateTaskResult the augmented call returned, or a bare task id.

required
read_timeout_seconds float | None

Per-request read timeout for each tasks/get round; defaults to the session's.

None

Raises:

Type Description
TaskFailedError

The task reached failed; carries the inlined JSON-RPC error.

TaskCancelledError

The task reached cancelled.

TaskInputRequiredError

The task reached input_required (the in-task input loop is not implemented yet).

RuntimeError

The server violated SEP-2663 — a completed snapshot without result, or a failed snapshot without error.

MCPError

A tasks/get round failed on the wire: -32602 (invalid params) for an unknown or expired task id, -32021 (missing required client capability) when this modern client did not declare the extension, or -32601 (method not found) on a legacy (2025-11-25) connection.

Source code in src/mcp/client/tasks.py
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
async def wait_task(
    session: ClientSession,
    task: str | CreateTaskResult,
    *,
    read_timeout_seconds: float | None = None,
) -> CallToolResult:
    """Poll an SEP-2663 task to a terminal status and return its final `CallToolResult`.

    The manual counterpart of the transparent `TasksExtension` flow, raising the
    same typed errors. Pass the `CreateTaskResult` and its `pollIntervalMs` hint
    seeds the polling cadence; pass a bare task id and a client that reconnected
    — or restarted with only the persisted id — resumes a task it no longer
    holds the `CreateTaskResult` for.

    The wait itself is unbounded (how long to keep polling is the caller's
    policy — cancel via an enclosing anyio cancel scope);
    `read_timeout_seconds` bounds each `tasks/get` round, not the whole wait.

    Args:
        session: The session to poll on.
        task: The `CreateTaskResult` the augmented call returned, or a bare
            task id.
        read_timeout_seconds: Per-request read timeout for each `tasks/get`
            round; defaults to the session's.

    Raises:
        TaskFailedError: The task reached `failed`; carries the inlined JSON-RPC error.
        TaskCancelledError: The task reached `cancelled`.
        TaskInputRequiredError: The task reached `input_required` (the in-task
            input loop is not implemented yet).
        RuntimeError: The server violated SEP-2663 — a `completed` snapshot
            without `result`, or a `failed` snapshot without `error`.
        MCPError: A `tasks/get` round failed on the wire: `-32602` (invalid
            params) for an unknown or expired task id, `-32021` (missing
            required client capability) when this modern client did not declare
            the extension, or `-32601` (method not found) on a legacy
            (2025-11-25) connection.
    """
    if isinstance(task, str):
        task_id, initial_interval_ms = task, None
    else:
        task_id, initial_interval_ms = task.task_id, task.poll_interval_ms

    async def poll(task_id: str) -> GetTaskResult:
        return await get_task(session, task_id, read_timeout_seconds=read_timeout_seconds)

    return await run_task_driver(task_id, initial_interval_ms, get_task=poll, sleep=anyio.sleep)

update_task async

update_task(
    session: ClientSession,
    task_id: str,
    input_responses: dict[str, Any],
    *,
    read_timeout_seconds: float | None = None
) -> None

Answer an SEP-2663 task's in-task input requests (tasks/update).

input_responses maps keys of the snapshot's inputRequests to their answers; servers should ignore responses for keys the task never issued (SEP-2663). The server acknowledges with an empty result, which is swallowed.

Parameters:

Name Type Description Default
session ClientSession

The session to send on.

required
task_id str

The task id a CreateTaskResult carried.

required
input_responses dict[str, Any]

Answers keyed by the snapshot's inputRequests keys.

required
read_timeout_seconds float | None

Per-request read timeout; defaults to the session's.

None

Raises:

Type Description
MCPError

-32602 (invalid params) for an unknown or expired task id, -32021 (missing required client capability) when this modern client did not declare the extension, or -32601 (method not found) on a legacy (2025-11-25) connection.

Source code in src/mcp/client/tasks.py
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
async def update_task(
    session: ClientSession,
    task_id: str,
    input_responses: dict[str, Any],
    *,
    read_timeout_seconds: float | None = None,
) -> None:
    """Answer an SEP-2663 task's in-task input requests (`tasks/update`).

    `input_responses` maps keys of the snapshot's `inputRequests` to their
    answers; servers should ignore responses for keys the task never issued
    (SEP-2663).
    The server acknowledges with an empty result, which is swallowed.

    Args:
        session: The session to send on.
        task_id: The task id a `CreateTaskResult` carried.
        input_responses: Answers keyed by the snapshot's `inputRequests` keys.
        read_timeout_seconds: Per-request read timeout; defaults to the
            session's.

    Raises:
        MCPError: `-32602` (invalid params) for an unknown or expired task id,
            `-32021` (missing required client capability) when this modern
            client did not declare the extension, or `-32601` (method not
            found) on a legacy (2025-11-25) connection.
    """
    request = UpdateTaskRequest(params=UpdateTaskRequestParams(task_id=task_id, input_responses=input_responses))
    await session.send_request(request, EmptyResult, request_read_timeout_seconds=read_timeout_seconds)

cancel_task async

cancel_task(
    session: ClientSession,
    task_id: str,
    *,
    read_timeout_seconds: float | None = None
) -> None

Request cancellation of an SEP-2663 task (tasks/cancel).

Cancellation is cooperative and may never take effect: SEP-2663 lets the server finish the task anyway, and in this SDK the work has always finished before a tasks/cancel can arrive. The server acknowledges with an empty result, which is swallowed — follow with get_task to see the status that actually resulted.

Parameters:

Name Type Description Default
session ClientSession

The session to send on.

required
task_id str

The task id a CreateTaskResult carried.

required
read_timeout_seconds float | None

Per-request read timeout; defaults to the session's.

None

Raises:

Type Description
MCPError

-32602 (invalid params) for an unknown or expired task id, -32021 (missing required client capability) when this modern client did not declare the extension, or -32601 (method not found) on a legacy (2025-11-25) connection.

Source code in src/mcp/client/tasks.py
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
async def cancel_task(
    session: ClientSession,
    task_id: str,
    *,
    read_timeout_seconds: float | None = None,
) -> None:
    """Request cancellation of an SEP-2663 task (`tasks/cancel`).

    Cancellation is cooperative and may never take effect: SEP-2663 lets the
    server finish the task anyway, and in this SDK the work has always finished
    before a `tasks/cancel` can arrive. The server acknowledges with an empty
    result, which is swallowed — follow with `get_task` to see the status that
    actually resulted.

    Args:
        session: The session to send on.
        task_id: The task id a `CreateTaskResult` carried.
        read_timeout_seconds: Per-request read timeout; defaults to the
            session's.

    Raises:
        MCPError: `-32602` (invalid params) for an unknown or expired task id,
            `-32021` (missing required client capability) when this modern
            client did not declare the extension, or `-32601` (method not
            found) on a legacy (2025-11-25) connection.
    """
    request = CancelTaskRequest(params=CancelTaskRequestParams(task_id=task_id))
    await session.send_request(request, EmptyResult, request_read_timeout_seconds=read_timeout_seconds)

TasksExtension

Bases: ClientExtension

SEP-2663 Tasks as a client extension.

Declares io.modelcontextprotocol/tasks and claims the task resultType on tools/call: a CreateTaskResult is resolved by polling tasks/get to the final CallToolResult, exactly as wait_task does by hand.

Source code in src/mcp/client/tasks.py
318
319
320
321
322
323
324
325
326
327
328
329
class TasksExtension(ClientExtension):
    """SEP-2663 Tasks as a client extension.

    Declares `io.modelcontextprotocol/tasks` and claims the `task` resultType on
    `tools/call`: a `CreateTaskResult` is resolved by polling `tasks/get` to the
    final `CallToolResult`, exactly as `wait_task` does by hand.
    """

    identifier = EXTENSION_ID

    def claims(self) -> Sequence[ResultClaim[Any]]:
        return (ResultClaim(result_type="task", model=CreateTaskResult, resolve=_resolve_created_task),)