MCP tasks let a Model Context Protocol server answer a slow tool call with a durable task handle instead of a final result, so the client can poll for status, supply input midway, cancel, and collect the result later. Tasks arrived as an experimental feature in the 2025-11-25 specification and moved into an official extension, io.modelcontextprotocol/tasks, in the 2026-07-28 revision.
To secure them, treat every task ID as a capability. Generate it with real entropy, check authorization on every tasks/get, tasks/update and tasks/cancel, cap time to live and concurrency, log every access, and treat a result read an hour later as untrusted input, exactly like one read a second later.
Key takeaways
- Two generations exist and they are not wire compatible. The 2025-11-25 experimental design uses a
taskparameter on the request,tasks/get, a blockingtasks/result,tasks/listandtasks/cancel. The 2026-07-28 extension dropstasks/resultandtasks/list, addstasks/update, and lets the server decide per request whether to return a task. - Five statuses, in both generations:
working,input_required,completed,failedandcancelled. The last three are terminal. - A task ID can be a bearer token. The extension says a server may use task IDs as bearer tokens for stored state and must generate them with enough entropy that nobody can guess or enumerate them.
- Authorization is per request. The 2025-11-25 text requires binding tasks to the authorization context when there is one; the extension requires authentication and authorization checks on every task request.
- Cancellation is cooperative. In the extension,
tasks/cancelreturns an empty acknowledgement and the work may still finish ascompleted. - Results outlive the conversation that asked for them. Retention, time to live and the prompt injection risk of stale results are the new problems tasks add.
- SDK support is uneven. The TypeScript SDK's v1 line ships the 2025-11-25 design as an experimental API; the official
@modelcontextprotocol/ext-taskspackage targets both generations from the client side; the Python SDK's tasks example is marked deferred.
What are MCP tasks, and which spec revision introduced them?
A normal MCP tool call is a JSON-RPC request that waits for its response (our MCP explainer covers the message and transport basics). That works for a lookup that takes 200 milliseconds. It works badly for a dependency scan of a monorepo, a CI pipeline run, a report that takes ten minutes to build, or anything that needs a human approval in the middle. The connection has to stay open, intermediaries time out, and if the client restarts, the work is orphaned.
Tasks fix that by splitting "start the work" from "collect the result". The 2025-11-25 tasks page describes tasks as "durable state machines" that carry the execution state of the request they wrap, intended for polling and deferred result retrieval. Each one has an ID generated by the side doing the work.
Where tasks came from
The proposal was SEP-1686, authored by Surbhi Bansal and Luca Chang, with a creation date of 20 October 2025 in the SEP document. Its motivation section describes the workaround servers used before: split one slow tool into a start_ tool that returns a job ID, a status tool and a result tool, and hope the model calls them in the right order. Tasks moved that pattern into the protocol.
The 2025-11-25 changelog lists "experimental support for tasks" among the changes in that release, and the tasks page itself carries a note that tasks "are currently considered experimental" and may change.
What changed in 2026-07-28
They did change. The MCP versioning page names 2026-07-28 as the current protocol version, and the 2026-07-28 changelog lists, as a major change, moving experimental tasks "out of the core protocol and into an official extension". The redesign is SEP-2663, created on 27 April 2026 by Luca Chang and Caitie McCaffrey on behalf of the Agents Working Group. Its pull request was merged on 15 May 2026, and the specification now lives in the ext-tasks repository, whose README lists the 2026-07-28 schema as Stable.
SEP-2663 explains why. Implementers found the handshake fragile, because a client had to call tools/list before it knew whether it could attach a task parameter. They found tasks/result a "blocking trap", because a client that saw input_required had to open a long-lived stream so the server had somewhere to send its elicitation. And they found tasks/list could not be scoped safely once the 2026-07-28 revision removed protocol sessions, since there was no natural scope left to filter by. That last point is a security decision as much as a design one, and we come back to it below.
One small inconsistency is worth knowing if you read the SEPs: the SEP-2663 text refers to the base protocol changes as part of a "2026-06-30" specification, while the published revision is labelled 2026-07-28. The official changelog and versioning page use 2026-07-28, so this post does too.
The two generations side by side
| 2025-11-25 (experimental, core) | 2026-07-28 (official extension) | |
|---|---|---|
| Where it is specified | Core spec, Basic, Utilities, Tasks | ext-tasks repository, extension io.modelcontextprotocol/tasks |
| Who decides a call becomes a task | Client, by adding task to the request params | Server, per request, if the client declared the extension |
| Negotiation | capabilities.tasks with requests, list, cancel, plus per-tool execution.taskSupport | extensions entry in client capabilities on each request and in server/discover |
| Requests that can become tasks | tools/call on servers; sampling/createMessage and elicitation/create on clients | tools/call only |
| Creation response | CreateTaskResult with a nested task object | CreateTaskResult with resultType: "task" and task fields at the top level |
| Poll | tasks/get | tasks/get, which also returns the result or error once terminal |
| Fetch result | tasks/result, which blocks until terminal | No separate method; tasks/result must return Method not found |
| Supply input midway | Server sends requests tagged with the task, usually over the tasks/result stream | inputRequests in the tasks/get response, answered with tasks/update |
| List tasks | tasks/list, optional, paginated | Removed |
| Cancel | tasks/cancel returns the task in cancelled status | tasks/cancel returns an empty acknowledgement |
| Time fields | ttl, pollInterval (milliseconds) | ttlMs, pollIntervalMs (milliseconds) |
Tool error with isError: true | Task ends failed | Task ends completed, error inside result |
| Push updates | Optional notifications/tasks/status | Optional notifications/tasks via subscriptions/listen |
| Progress notifications | Supported; the progressToken stays valid for the task's life | Not supported on tasks in this version |
SEP-2663 says plainly that the 2025-11-25 feature is "not wire-compatible" with the extension, and suggests SDKs shim the two by dispatching on the negotiated protocol version. If you run servers or clients in production today, you will likely meet both generations for a while.
How does the task lifecycle work?
Statuses and transitions
Both generations share the same five statuses:
| Status | Meaning | Terminal? |
|---|---|---|
working | The request is being processed | No |
input_required | The receiver needs something from the requestor before it can continue | No |
completed | Finished, result available | Yes |
failed | Finished with an error | Yes |
cancelled | Cancelled before completion | Yes |
The 2025-11-25 text requires every task to begin in working. From working it may move to input_required or to any terminal status; from input_required it may go back to working or to any terminal status; and a terminal task "MUST NOT" move again. The extension keeps the same state diagram, though it describes the initial status as "typically (though not necessarily)" working.
The 2025-11-25 flow
In the experimental design, the client opts in by adding a task object to the request params, optionally with a requested ttl in milliseconds:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "scan_repository",
"arguments": { "repo": "acme/payments" },
"task": { "ttl": 600000 }
}
}
The server answers at once with a CreateTaskResult. The tool output is not in it:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"task": {
"taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
"status": "working",
"createdAt": "2025-11-25T10:30:00Z",
"lastUpdatedAt": "2025-11-25T10:30:00Z",
"ttl": 600000,
"pollInterval": 5000
}
}
}
From there the client has four methods:
tasks/getreturns the current task state. Requestors "SHOULD respect" thepollIntervaland should keep polling until a terminal status orinput_required.tasks/resultreturns exactly what the original request would have returned, whether a result or a JSON-RPC error. If the task is not yet terminal, the receiver "MUST block the response" until it is. The response carriesio.modelcontextprotocol/related-taskin_meta, because the result itself has no task ID in it.tasks/listreturns tasks with cursor pagination, if the receiver declaredtasks.list.tasks/cancelmoves the task tocancelledand returns it. Cancelling a task that is already terminal must fail with-32602(Invalid params).
The spec also suggests an optional _meta key, io.modelcontextprotocol/model-immediate-response, on the CreateTaskResult. Its value is a string a host can pass straight to the model as a placeholder tool result, so the model can keep working while the task runs. The spec calls this "non-binding and provisional".
The 2026-07-28 extension flow
In the extension the client does not flag individual calls. It includes the extension in the client capabilities it sends in _meta on each request, and the server decides:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "scan_repository",
"arguments": { "repo": "acme/payments" },
"_meta": {
"io.modelcontextprotocol/clientCapabilities": {
"extensions": { "io.modelcontextprotocol/tasks": {} }
}
}
}
}
If the server chooses a task, it returns a result whose resultType is "task":
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"resultType": "task",
"taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
"status": "working",
"createdAt": "2026-10-24T10:30:00Z",
"lastUpdatedAt": "2026-10-24T10:30:00Z",
"ttlMs": 3600000,
"pollIntervalMs": 5000
}
}
The client then polls tasks/get. While the task runs, the response is the task object with resultType: "complete" (the normal result type for the tasks/get method itself). Once the task is terminal, the same call carries the outcome inline: a result field for completed, an error field for failed. There is no separate result fetch.
Two requirements in the extension make clients simpler and are worth checking in any server you deploy:
- A server "MUST NOT return
CreateTaskResultuntil the task is durably created", meaning until atasks/getfor that ID would resolve. In an eventually consistent store, the server has to wait. - A server "MUST NOT return
CreateTaskResultto a client that did not include the extension capability on its request". If the server cannot serve that client without a task, it returns error-32021(Missing Required Client Capability).
Clients, for their part, "SHOULD persist task IDs to durable storage so that polling can resume after a crash or restart". That one sentence is the reason tasks exist, and also the reason task IDs need protecting.
Time to live and poll intervals
In both generations the requestor's lifetime is a request, not a promise. In 2025-11-25 the receiver "MAY override the requested ttl", must report the actual value (or null for unlimited), and "MAY delete the task and its results" once it expires, "regardless of the task status". Returning "task not found" for a purged task is explicitly compliant.
The extension keeps that model with ttlMs and adds two details. Both ttlMs and pollIntervalMs may change during a task's life, and a server may mark a task failed once the TTL elapses and delete it after that. A server "MAY rate-limit clients polling more frequently than the recorded pollIntervalMs", which gives operators a protocol-blessed reason to throttle aggressive pollers.
A worked example of what those numbers mean in practice: a client polling every 5 seconds for a 30 minute job sends about 360 tasks/get requests. One hundred users doing the same is 36,000 requests per half hour against your task store, before any of them retries on error. Publishing a longer pollIntervalMs when the server knows the job is long, and offering notifications for clients that can hold a stream, is a cheap way to cut that by an order of magnitude.
How are tasks negotiated?
2025-11-25: two layers of opt-in
A server that supports tasks declares capabilities.tasks during initialization, broken down by request type:
{
"capabilities": {
"tasks": {
"list": {},
"cancel": {},
"requests": { "tools": { "call": {} } }
}
}
}
A client can declare the mirror image for the requests a server sends to it: tasks.requests.sampling.createMessage and tasks.requests.elicitation.create. Sampling has its own risks, covered in why MCP sampling needs a human in the loop. The spec calls the set in requests "exhaustive"; anything not listed does not support tasks.
Tool calls then get a second, finer layer. Each tool in tools/list may carry execution.taskSupport with one of three values:
execution.taskSupport | Client behaviour | Server behaviour |
|---|---|---|
absent or "forbidden" | MUST NOT call as a task | SHOULD return -32601 if it tries |
"optional" | MAY call either way | Accepts both |
"required" | MUST call as a task | MUST return -32601 for a plain call |
If the server's capabilities do not include tasks.requests.tools.call, the per-tool field is ignored and clients must not use tasks at all.
2026-07-28: one extension flag
The extension replaces all of that with a single entry, io.modelcontextprotocol/tasks, in the extensions object that clients send on every request and servers return from server/discover. No extension settings are defined yet; an empty object means "supported". Only tools/call can be task-augmented in this version, though the spec tells implementers to expect more request types later.
Because the server decides per request, a client that declares the extension must handle either a normal CallToolResult or a CreateTaskResult from any tool call. A server that never creates a task is still compliant. A client that receives a CreateTaskResult for a request type that does not support tasks must treat it as an invalid response.
The MCP extensions overview describes extensions as opt-in on both sides. When we checked the community-maintained extension support matrix on 24 October 2026, it did not yet include a column for Tasks, and the Tasks overview itself warns that host support "varies by client". Test the exact host you plan to use rather than assuming.
How do tasks interact with progress, cancellation and elicitation?
Progress
The progress utility lets a requestor put a progressToken in request metadata and receive notifications/progress with a progress value, an optional total and an optional message. Under 2025-11-25, the token "remains valid throughout the task lifetime", so a long task can keep reporting progress after the CreateTaskResult has gone back.
The extension takes the opposite line for now: notifications/progress and notifications/message "MUST NOT be sent on the subscriptions/listen stream for a task, and are not supported on tasks in general in this specification". Progress in the extension travels through statusMessage, which the spec says "MAY be exposed to the end-user or model". Keep that phrase in mind; it matters for prompt injection.
Cancellation
Ordinary MCP requests are cancelled with a notifications/cancelled notification naming the request ID, as described in the cancellation utility. For task-augmented requests, that page says tasks/cancel "MUST be used instead". The extension repeats the rule: notifications/cancelled "MUST NOT be used for task cancellation".
The two generations differ on what cancellation guarantees. Under 2025-11-25 a receiver "MUST transition the task to cancelled status before sending the response", and once cancelled a task stays cancelled even if the work finishes. Under the extension, cancellation is "cooperative": the server must acknowledge, but "is not obligated to actually stop the work", the status may stay working for a while, and the task "MAY ultimately reach a terminal status other than cancelled".
From a security point of view that difference matters. If a user cancels a task because they realised it was about to do the wrong thing, the extension does not promise the thing did not happen. Design servers so that destructive steps check a cancellation flag immediately before they act, and tell users honestly that cancellation is a request.
Elicitation and input_required
A task that needs something from a person, such as a confirmation before it deletes a branch or a choice between two deployment targets, moves to input_required.
In 2025-11-25, the receiver sends its elicitation or sampling request tagged with io.modelcontextprotocol/related-task, and the requestor that sees input_required "SHOULD preemptively call tasks/result" so the server has an open stream to deliver the request on. That is the "blocking trap" SEP-2663 complains about.
In the extension, the tasks/get response for an input_required task carries an inputRequests map. Each key is an arbitrary identifier; each value is an ordinary request such as elicitation/create. The client gathers the answers and sends them back with tasks/update:
{
"jsonrpc": "2.0",
"id": 6,
"method": "tasks/update",
"params": {
"taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
"inputResponses": {
"confirm_delete": { "action": "accept", "content": { "confirm": true } }
}
}
}
The server acknowledges with an empty result and the client goes back to polling. Keys must be unique for the life of a task and must never be reused, servers should ignore responses for keys that are not outstanding, and clients should deduplicate keys across polls so a person is not asked the same question twice.
The extension is explicit about trust here: clients "MUST treat each entry in inputRequests as they would the equivalent standalone server-to-client request", and the security section adds "A task is not a higher-trust channel." An elicitation that arrives inside a task deserves the same scrutiny as one that arrives directly, including the rule from our post on whether an MCP server can ask for a password or API key.
The extension also composes with the new Multi Round-Trip Requests pattern in 2026-07-28. A server that needs input before it decides to start a task uses MRTR on the original request; one that needs input during the task uses inputRequests and tasks/update. SEP-2663 says servers "SHOULD" finish any MRTR exchange before returning a CreateTaskResult.
What are the security issues with MCP tasks?
Tasks add state that outlives a single request. Anything that holds state for later has to answer four questions: who can read it, who can change it, how long it lives, and what happens when it is read. Each of the issues below is one of those questions going wrong.
1. Guessable or leaked task IDs
A task ID is the key to a result. The 2025-11-25 text says so directly: "any party that can guess or obtain a task ID could potentially access sensitive information or manipulate tasks they did not create." The extension goes further and says a server "MAY use task IDs as bearer tokens" and "MUST generate them with sufficient entropy that a third party cannot enumerate or guess them."
Sequential integers, timestamps, or a hash of the tool name and arguments all fail that test. This is the same class of flaw OWASP calls insecure direct object reference: the OWASP IDOR prevention cheat sheet recommends access control checks on every object, with hard-to-guess identifiers as defence in depth rather than a replacement. The reference in-memory store in the TypeScript SDK's v1 line generates IDs with randomBytes(16).toString('hex'), which is 128 bits from Node's cryptographic generator. Version 4 UUIDs, as in the spec examples, carry 122 random bits. Either is fine if the generator is cryptographically secure.
Leakage is the other half. Task IDs travel in tool results shown to the model, in logs, in browser developer tools, in support tickets and in screenshots. If a task ID is a bearer token, every one of those places is a credential store. Redact or hash task IDs in general application logs and keep the raw value only in the audit trail.
2. Binding tasks to the authorized caller
The 2025-11-25 rule: "When an authorization context is provided, receivers MUST bind tasks to said context", and they "MUST reject tasks/get, tasks/result, and tasks/cancel requests for tasks that do not belong to the same authorization context". Where binding is impossible, such as a single-user stdio server, the receiver should document the limitation, use cryptographically secure IDs and consider shorter TTLs.
The extension states the rule more simply: servers "MUST perform authentication and authorization checks on each task-related request". In practice that means storing the identity that created the task (OAuth subject, client ID and tenant, not just a session ID) and comparing it on every read, write and cancel.
Sessions are a weak anchor. The TypeScript SDK's v1 branch shows why: a pending changeset for its in-memory store makes the store remember the session a task was created with, and noted that "a client that comes back with a new session id does not see its earlier tasks", while servers without sessions, or custom stores that ignore the session, still share tasks with any caller that has the ID. In the 2026-07-28 revision protocol-level sessions are gone altogether, so binding has to be done to the authenticated principal. The MCP security best practices make the same point about sessions in general: servers "MUST use secure, non-deterministic session IDs" and "SHOULD bind session IDs to user-specific information", for example a key shaped like <user_id>:<session_id>.
3. Cross-tenant access and listing
tasks/list is where a poorly scoped server leaks the most. The 2025-11-25 spec says receivers that cannot identify requestors "SHOULD NOT declare the tasks.list capability", because listing would expose task metadata "regardless of task ID entropy". SEP-2663 removed tasks/list entirely and lists that as a security improvement: "a server cannot inadvertently leak the existence of one caller's tasks to another."
If you still serve 2025-11-25 clients, the safest option is not to declare tasks.list. If you must, filter by tenant and principal inside the store query, not after it, and return the same "not found" error for a task that exists in another tenant as for one that does not exist at all, so the error itself cannot be used to probe.
4. Resource exhaustion from abandoned tasks
A synchronous tool call frees its resources when the connection drops. A task does not; that is the point of it. A client that starts a thousand tasks and walks away leaves a thousand workers, a thousand rows and possibly a thousand external jobs behind.
The 2025-11-25 spec lists what receivers should do: enforce limits on concurrent tasks per requestor, enforce maximum TTLs, clean up expired tasks promptly, document both limits, and monitor resource use. Both generations allow a receiver to override the requested TTL, so a client asking for "ttl": null (unlimited) should get your maximum, not forever. Rate limiting task operations is a "SHOULD" in 2025-11-25 for denial of service and enumeration, and in the extension a server may throttle clients that poll faster than pollIntervalMs.
Remember that cancellation in the extension does not promise the work stopped. A cleanup job that only deletes rows will not stop the cloud build the task started. Cleanup must cancel the underlying job too.
5. Retention of sensitive results
A task result might hold a list of vulnerable services, an internal document, a database extract or a generated credential. Under a synchronous call that data lived in memory for the length of a request. Under tasks it sits in a store until the TTL runs out, and possibly in backups after that.
Decide retention per tool, not per server. A scan summary might keep for an hour; a result that contains personal data should be deleted on first successful retrieval or kept for minutes. Encrypt the task store at rest, keep it out of general-purpose caches, and make sure purge really deletes rather than marking rows inactive. The extension notes that clients "MAY delete all state associated with the task as soon as they send a cancellation", which is good practice for clients too: they should not hoard task IDs or results after they are done with them.
6. Prompt injection in results read much later
Tool output is model input. OWASP's LLM01 Prompt Injection entry describes indirect injection as an LLM accepting input "from external sources, such as websites or files". A task result is exactly that: content produced by a server, sometimes from third-party data, delivered into the model's context.
Tasks make the problem a little worse in three ways. First, the result can arrive long after the user asked, in a different conversation turn or a different session, when nobody remembers what the task was for. Second, statusMessage "MAY be exposed to the end-user or model", so a server or the data it processed can put text in front of the model on every poll, not just at the end. Third, inputRequests can carry elicitation and sampling payloads that look like they come from a trusted long-running job.
The controls are the usual ones, applied consistently: treat task results and status messages as untrusted content, keep the approval rules for any follow-up action the model takes on a result the same as for a fresh result, and do not grant a model more tools because a task "already ran". OWASP's guidance to "enforce privilege control and least privilege access" applies unchanged.
7. Audit gaps
The 2025-11-25 spec recommends that receivers log task creation, completion and retrieval with auth context, and watch for "many failed task lookups" and "excessive polling". Requestors should log lifecycle events and track task IDs against the operations that created them. Without that, an investigator cannot answer the basic question after an incident: who read this result, and when?
Summary of threats and controls
| Threat | What it looks like | Control |
|---|---|---|
| Guessable task ID | Sequential or derived IDs; another user fetches a result | 128 bits from a CSPRNG; treat ID as a secret |
| Leaked task ID | ID appears in logs, tickets, screenshots | Redact in app logs; short TTL; bind to principal |
| Missing auth binding | Any caller with the ID can read or cancel | Store creator identity; check on every get, update, cancel |
| Cross-tenant listing | tasks/list returns other tenants' tasks | Do not declare tasks.list; filter in the query |
| Enumeration | Many failed lookups from one client | Rate limit; identical not-found errors; alert |
| Abandoned tasks | Thousands of idle tasks hold workers and jobs | Per-principal concurrency caps; max TTL; cancel underlying jobs |
| Cancel not honoured | User cancels but the destructive step still runs | Check cancellation before each side effect; say cancel is a request |
| Sensitive retention | Results sit in a store or backups for days | Per-tool retention; delete on retrieval; encrypt at rest |
| Stale injection | Old result or status message steers the model | Treat as untrusted; same approvals for follow-up actions |
Untrusted inputRequests | Elicitation inside a task asks for secrets | Same trust model as direct elicitation; never collect credentials in forms |
How do MCP tasks compare with A2A tasks, HTTP 202 polling and webhooks?
A2A tasks
The Agent2Agent protocol specification, at version 1.0.0 when we checked, makes tasks central rather than optional. An agent may answer SendMessage with a direct message or create a task, and tasks have a richer state set: TASK_STATE_SUBMITTED, TASK_STATE_WORKING, TASK_STATE_INPUT_REQUIRED, TASK_STATE_AUTH_REQUIRED, and the terminal states TASK_STATE_COMPLETED, TASK_STATE_FAILED, TASK_STATE_CANCELED and TASK_STATE_REJECTED. A2A offers GetTask, ListTasks, CancelTask, streaming through SubscribeToTask, and push notifications to a webhook the client registers.
A2A also keeps a task list, which MCP removed, and solves the scoping problem by rule: servers "MUST implement authorization checks on every A2A Protocol Operations request", and ListTasks "MUST only return tasks visible to the authenticated client". Its push notification section asks agents to validate webhook URLs to prevent server-side request forgery, including rejecting private IP ranges and localhost, and asks clients to verify that the task ID in a webhook payload matches one they created. Our post on A2A versus MCP on the wire covers the wider differences.
HTTP 202 Accepted with polling
The oldest version of this pattern is HTTP itself. RFC 9110 defines 202 Accepted as "intentionally noncommittal": the request was accepted for processing that has not finished, and the response "ought to describe the request's current status and point to (or embed) a status monitor". REST APIs implement that as a 202 with a Location header pointing at a job resource. MCP tasks are the same idea expressed in JSON-RPC, with the status monitor addressed by taskId instead of a URL. The security lessons carry over directly: job URLs that are guessable, or job resources that do not check ownership, have leaked data in plenty of ordinary web APIs.
Webhooks
Webhooks flip the direction: the server calls the client when the work is done. They cut polling to zero, but the client must expose a reachable endpoint, the server must make outbound requests to a URL a client supplied, and both sides need authentication for the callback. MCP's extension has no webhook mechanism; its push option is notifications/tasks over a subscriptions/listen stream the client opens, which avoids inbound connections to the client entirely.
Comparison table
| Synchronous MCP tool call | MCP task (2026-07-28 extension) | A2A task | Webhook callback | |
|---|---|---|---|---|
| Latency to first response | Whole job duration | Immediate handle | Immediate task or direct message | Immediate acknowledgement |
| Who waits | Client holds the request open | Client polls or listens | Client polls, streams or receives push | Client waits for an inbound call |
| State held by server | None after response | Task record and result until TTL | Task, history and artifacts | Job record and callback config |
| Survives client restart | No | Yes, if client persisted the ID | Yes | Yes, if the endpoint is up |
| Mid-job human input | Via elicitation during the call | input_required, inputRequests, tasks/update | TASK_STATE_INPUT_REQUIRED and TASK_STATE_AUTH_REQUIRED | Usually out of band |
| Cancellation | notifications/cancelled | tasks/cancel, cooperative | CancelTask | Provider specific |
| Listing | Not applicable | Removed | ListTasks, scoped to caller | Provider specific |
| Main failure mode | Timeouts, orphaned work | Abandoned tasks, lost IDs | Same, plus webhook delivery | Missed or forged callbacks |
| Key security controls | Per-call auth; tool approval | Unguessable IDs; auth on every task call; TTL caps | Auth on every operation; scoped lists; webhook SSRF checks | Signed callbacks; URL validation; replay protection |
The short version: use a synchronous call when the job reliably finishes in seconds, an MCP task when a tool behind an MCP server needs minutes or a human, an A2A task when one agent is delegating a whole piece of work to another, and a webhook when you control both ends and polling would be too costly.
Step by step: add task support, poll and cancel, and log access
This walkthrough uses the TypeScript SDK because it is the one official SDK with a shipped task API at the time of writing. Check the versions before you start:
npm view @modelcontextprotocol/sdk version
npm view @modelcontextprotocol/ext-tasks version
pip index versions mcp
When we wrote this, @modelcontextprotocol/sdk was at 1.32.2, @modelcontextprotocol/ext-tasks at 0.2.2 and the Python mcp package at 2.3.0. The SDK's v1 line exposes the 2025-11-25 design under an experimental namespace, with a warning that the APIs "may change without notice". The Python SDK's tasks example in its main branch is marked "Status: deferred", pending a runtime built to the 2026-07-28 extension. The ext-tasks package provides client-side "generation-agnostic" calls that work against 2025-11-25 servers and, with extra adapter options, against 2026-07-28 servers.
Pin exact versions in package.json and commit your lockfile. Experimental APIs are exactly where a minor release can change behaviour.
Step 1: a task store that binds tasks to the caller
The v1 SDK defines a TaskStore interface with createTask, getTask, storeTaskResult, getTaskResult, updateTaskStatus and listTasks, and every method receives the session ID of the request. Its docs ask stores shared by several sessions to "treat a task as not found" when a different session asks for it. We go one step further and bind to the authenticated principal. The sketch below wraps the SDK's InMemoryTaskStore; in production, back it with a database and keep the same checks.
// owned-task-store.ts
import { InMemoryTaskStore } from "@modelcontextprotocol/sdk/experimental/tasks";
type Owner = { tenant: string; subject: string };
export class OwnedTaskStore extends InMemoryTaskStore {
private owners = new Map<string, Owner>();
private active = new Map<string, number>();
constructor(
private currentOwner: () => Owner, // read from your auth middleware
private audit: (e: object) => void,
private maxTtlMs = 60 * 60 * 1000,
private maxActivePerOwner = 20
) { super(); }
private key(o: Owner) { return `${o.tenant}:${o.subject}`; }
async createTask(params: any, requestId: any, request: any, sessionId?: string) {
const owner = this.currentOwner();
const k = this.key(owner);
if ((this.active.get(k) ?? 0) >= this.maxActivePerOwner) {
this.audit({ event: "task.rejected.concurrency", owner: k });
throw new Error("Too many active tasks");
}
const ttl = Math.min(params.ttl ?? this.maxTtlMs, this.maxTtlMs); // never unlimited
const task = await super.createTask({ ...params, ttl }, requestId, request, sessionId);
this.owners.set(task.taskId, owner);
this.active.set(k, (this.active.get(k) ?? 0) + 1);
this.audit({ event: "task.created", owner: k, task: task.taskId, tool: request?.params?.name, ttl });
return task;
}
private check(taskId: string, op: string) {
const owner = this.currentOwner();
const ok = this.owners.get(taskId);
const allowed = !!ok && this.key(ok) === this.key(owner);
this.audit({ event: `task.${op}`, owner: this.key(owner), task: taskId, allowed });
return allowed;
}
async getTask(taskId: string, sessionId?: string) {
return this.check(taskId, "get") ? super.getTask(taskId, sessionId) : null; // same as unknown
}
async getTaskResult(taskId: string, sessionId?: string) {
if (!this.check(taskId, "result")) throw new Error("Task not found");
return super.getTaskResult(taskId, sessionId);
}
async listTasks(cursor?: string, sessionId?: string) {
const owner = this.key(this.currentOwner());
const page = await super.listTasks(cursor, sessionId);
return { ...page, tasks: page.tasks.filter(t => {
const o = this.owners.get(t.taskId); return !!o && this.key(o) === owner; }) };
}
}
Three things in that sketch map straight to the spec. A task from another principal looks exactly like a missing task, so lookups cannot be used to probe. The requested TTL is capped, because receivers "MAY override the requested ttl". And every access is logged with the principal, as the audit section recommends. Filtering listTasks after the fact is acceptable for an in-memory demo; a database store should filter in the query and paginate per owner. Better still, do not declare tasks.list at all. You will also want to decrement the active counter when a task reaches a terminal status or expires, and to cancel the underlying job when cleanup runs.
Step 2: register a tool that requires tasks
With the v1 experimental API, you pass the store to the server and register a task tool with server.experimental.tasks.registerToolTask. The handler implements createTask, getTask and getTaskResult, and the tool declares execution.taskSupport:
// server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { OwnedTaskStore } from "./owned-task-store.js";
import { currentOwner, audit } from "./auth.js"; // your code
const taskStore = new OwnedTaskStore(currentOwner, audit);
const server = new McpServer(
{ name: "repo-scanner", version: "1.0.0" },
{
capabilities: { tasks: { cancel: {}, requests: { tools: { call: {} } } } }, // no "list"
taskStore,
}
);
server.experimental.tasks.registerToolTask(
"scan_repository",
{
description: "Scan a repository's dependencies. Takes several minutes.",
inputSchema: { repo: z.string().regex(/^[\w.-]+\/[\w.-]+$/) },
execution: { taskSupport: "required" },
},
{
createTask: async ({ repo }, extra) => {
const task = await extra.taskStore.createTask({ ttl: 15 * 60 * 1000 });
startScan(task.taskId, repo, extra.taskStore); // background work, checks for cancel
return { task };
},
getTask: async (_args, extra) => extra.taskStore.getTask(extra.taskId),
getTaskResult: async (_args, extra) => extra.taskStore.getTaskResult(extra.taskId),
}
);
The capabilities block deliberately omits list. The SDK's own interactive example sets the same tasks.requests.tools.call capability and marks its tools taskSupport: 'required'. startScan should store the final result with storeTaskResult(taskId, "completed", result) and, before every side effect, re-read the task and stop if its status is cancelled.
Step 3: call, poll and cancel from a client
The v1 client exposes client.experimental.tasks.callToolStream, which sends the task-augmented call and yields taskCreated, taskStatus, result and error messages as the task progresses. It also exposes getTask, getTaskResult, listTasks and cancelTask.
// client.ts
import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
const stream = client.experimental.tasks.callToolStream(
{ name: "scan_repository", arguments: { repo: "acme/payments" } },
CallToolResultSchema,
{ task: { ttl: 15 * 60 * 1000 } }
);
let taskId: string | undefined;
const deadline = Date.now() + 20 * 60 * 1000;
for await (const msg of stream) {
if (msg.type === "taskCreated") {
taskId = msg.task.taskId;
await saveTaskId(taskId); // durable, so a restart can resume
} else if (msg.type === "taskStatus") {
log("task.status", { taskId, status: msg.task.status });
if (Date.now() > deadline && taskId) {
await client.experimental.tasks.cancelTask(taskId);
log("task.cancel.requested", { taskId });
break;
}
} else if (msg.type === "result") {
handleUntrusted(msg.result); // tool output is untrusted model input
} else if (msg.type === "error") {
log("task.error", { taskId, error: String(msg.error) });
}
}
After a restart, read the saved ID and call client.experimental.tasks.getTask(taskId) to resume. If the server says the task is not found, assume it expired and start again; do not retry forever.
Step 4: the same thing against a 2026-07-28 server
For 2026-07-28 servers the shape changes: no task parameter, no tasks/result, and tasks/update for input. The @modelcontextprotocol/ext-tasks README shows a client session that "handles immediate results and durable task-backed execution" with one call:
import { createTaskSessionFromClient, resultFromTaskOutcome }
from "@modelcontextprotocol/ext-tasks/client";
const session = createTaskSessionFromClient(client, {
endpointId: "repo-scanner",
// A 2026-07-28 Tasks server also needs rawDispatch and v2RequestFraming;
// see the package's adapters guide.
});
try {
const execution = await session.callTool("scan_repository", { repo: "acme/payments" });
const { outcome } = await execution.settle();
const result = resultFromTaskOutcome(outcome);
} finally {
await session.close();
}
If you are writing your own client, the loop is simple enough to read on the wire: send tools/call with the extension in _meta; if resultType is "task", store taskId, then call tasks/get every pollIntervalMs until the status is terminal; answer any inputRequests once each via tasks/update; send tasks/cancel when the user gives up. Over Streamable HTTP (see choosing an MCP transport), the extension also requires the client to set the Mcp-Name header to the task ID on tasks/get, tasks/update and tasks/cancel, so load balancers can route the request to the instance that holds the task. If your gateway authorizes by header, authorize on the authenticated principal, not on Mcp-Name, which only tells the router where the task lives.
Step 5: log task access for audit
Write one structured event per task operation, on both sides. A useful minimum:
{"ts":"2026-10-24T10:31:05Z","event":"task.get","task":"sha256:9f2c...","tenant":"t-41","subject":"user-1882","client_id":"desktop-app","tool":"scan_repository","status":"working","allowed":true,"source_ip":"203.0.113.7"}
Hash the task ID in shared logs and keep the raw value only in a restricted audit store. Then alert on the patterns the spec names: a burst of allowed:false lookups from one principal (enumeration), polling far faster than the published interval (abuse or a broken client), and tasks that sit in working past their TTL (stuck jobs that still hold resources).
A quick test you can run against your own server before shipping: create a task as user A, then from user B's credentials call tasks/get, tasks/result (on 2025-11-25) and tasks/cancel with A's task ID. Every call should fail with the same not-found error a random ID produces, and each attempt should appear in the audit log.
Common mistakes
- Using the task ID as the only access control. Entropy stops guessing; it does not stop a leaked ID. Check the caller's identity on every task request.
- Binding tasks to the session and calling it done. Sessions change on reconnect, some transports have none, and 2026-07-28 removes protocol sessions. Bind to the authenticated principal and tenant.
- Declaring
tasks.liston a server that cannot identify callers. The 2025-11-25 spec says not to. The extension removed it for this reason. - Honouring
ttl: nullfrom clients. Unlimited lifetime for client-chosen tasks invites resource exhaustion. Cap it. - Treating cancellation as a guarantee. In the extension it is a request. Check for cancellation before each side effect and tell users what cancel means.
- Mixing the generations. Sending a
taskparameter to a 2026-07-28 server does nothing, and callingtasks/resultthere returns Method not found. Detect the protocol version and use the matching flow. - Keeping results forever. A task store becomes a quiet archive of sensitive output. Set per-tool retention and really delete.
- Trusting a result because it came from "our" long-running job. A result read later is still tool output. Apply the same approvals to any action the model takes on it.
FAQ
What is an MCP task in simple terms?
It is a receipt for a slow tool call. Instead of waiting for the result, the client gets a task ID straight away, checks back with tasks/get until the work is finished, and then reads the result. The client can also cancel the task or answer a question the server asks along the way.
Which MCP version added tasks?
Tasks were added as an experimental feature in the 2025-11-25 specification through SEP-1686. In the 2026-07-28 revision they moved out of the core protocol into the official extension io.modelcontextprotocol/tasks, defined by SEP-2663, with a redesigned and incompatible wire format.
What are the MCP task statuses?
working, input_required, completed, failed and cancelled. Tasks start in working, can move to and from input_required, and stop changing once they reach one of the three terminal states.
What is the difference between tasks/get and tasks/result?
In 2025-11-25, tasks/get returns the task's status without blocking, while tasks/result returns the actual tool output and blocks until the task is finished. The 2026-07-28 extension removes tasks/result; tasks/get returns the result or error inline once the task is terminal.
How do I cancel an MCP task?
Send tasks/cancel with the task ID. Do not use notifications/cancelled, which both generations forbid for tasks. In 2025-11-25 the task moves to cancelled before the response. In the extension the server only acknowledges, and the work may still finish.
Can someone else read my MCP task results?
They should not be able to. Servers must generate task IDs that cannot be guessed and must check authorization on every task request. If a server runs without authentication, such as a local single-user tool, anyone who can reach it and obtains the ID may read the result, which is why the 2025-11-25 spec asks such servers to document that limit and use short TTLs.
How long are MCP task results kept?
As long as the task's time to live, which the server sets and may shorten. After the TTL the server may delete the task and its result whatever its status, and a "not found" error for an expired task is compliant. Clients should collect results promptly and not depend on them lasting.
How are MCP tasks different from A2A tasks?
MCP tasks wrap a single tool call between a client and a server. A2A tasks represent a unit of work one agent delegates to another, with more states (including submitted, rejected and auth required), message history, artifacts, streaming and webhook push notifications. A2A keeps a scoped task list; the MCP extension removed its list.
Which SDKs support MCP tasks today?
The TypeScript SDK's v1 line has an experimental implementation of the 2025-11-25 design. The official @modelcontextprotocol/ext-tasks package offers client-side support for both generations. The Python SDK's tasks example is marked deferred until it implements the 2026-07-28 extension. Check each repository before you build, since this is moving quickly.
Where Safeguard fits
Safeguard does not implement MCP tasks, does not run your task store, and does not detect prompt injection in task results. The honest overlap is in three places.
First, Safeguard runs as an MCP server itself, so the same discipline applies to how you connect it. The download page says to add Safeguard from Claude's connector directory on web, desktop or mobile, with a one-time OAuth sign-in and no API key to paste, and to install Safeguard from the ChatGPT plugin directory. Use those directory routes rather than hand-configuring a custom server; the add-by-URL and schema import options are there as fallbacks. Either way, choose which Safeguard tools the assistant may use.
Second, MCP Guardrail, which generates guardrail proxy URLs that sit in front of MCP servers and govern agent tool traffic, is in early access. If you are evaluating a control point for tool calls between assistants and servers, ask about it, and do not plan around it as a generally available feature yet.
Third, every MCP server and client in this post is ordinary software with a dependency tree. The SDKs are npm and PyPI packages, the experimental APIs change between releases, and task stores pull in database drivers, queues and HTTP frameworks. Safeguard scans repositories, container images and SBOM files for vulnerable components, applies reachability analysis, shows which projects carry a given version, and gates builds with policies, which is how you keep the code behind your task endpoints current.
Tasks change how long state lives inside an MCP server, not what the server is made of: the SDK, the task store's database driver and the HTTP stack are still open source dependencies that ship in your image. When one of them has a vulnerability, the same two gaps apply as for any other component, and the fit here is the supply chain under your MCP servers rather than the task protocol itself.
Fix it without waiting for upstream
Whatever the vulnerability, most of the risk sits in two gaps. The first is the time between a fix being published and that fix actually running in your production builds. The second, and worse, is the time before any fix exists at all, while a maintainer works out a patch and a release schedule. Waiting through both gaps is a habit, not a requirement. Safeguard gives teams three ways to close them.
1. Autonomous remediation: pull requests, not tickets
When a fixed version exists, Safeguard's Griffin models open a tested, policy gated pull request that moves the affected dependency to the safe version. You can run it on one repository or in bulk across every project that carries the vulnerable version. Remediation covers eight categories, including dependencies, licenses, security posture and findings. Reachability analysis ranks the vulnerable code paths your application actually calls first, so the first pull requests you merge are the ones that matter most. Autonomous remediation ships switched off, with every category set to Manual, so your team decides how much runs on its own and what still needs a human approval.
2. Gold Open Source: the safe version in seconds, free
gold.safeguard.sh is free and needs no login. Search any CVE or package to see affected and fixed versions, CVSS, EPSS, CISA KEV status and a recommended safe version, or use the "Am I affected?" checker. The same data is available through a free read API, README status badges and RSS threat feeds. The safeguard-sh/gold-scan-action GitHub Action can fail a build on a known exploited vulnerability, on any known vulnerability, or never, so a version you have just removed cannot quietly come back in next week's merge.
3. Gold Registry: start from components that are already fixed
registry.safeguard.sh provides hardened, drop in replacements for upstream packages and container images. Every Gold artifact is held continuously to zero exploitable critical or high CVEs, zero malware and a completed license check, with SLSA L3 or higher provenance, a Sigstore signature and a public evidence page you can hand to an auditor. An artifact loses its Gold badge if it regresses. Teams can point their own package directory at the registry so hardened equivalents resolve without changing how developers install anything.
When upstream has no fix yet, or will not ship one, Safeguard first works to get the fix accepted upstream. If the issue is severe or upstream will not move, Safeguard builds a fork, fixes the vulnerability there and publishes a hardened Gold build, so you are not stuck on a maintainer's release schedule. You can also request a hardened, compatibility tested build of a specific package. This fork and patch flow is in early access today, so ask which packages it covers before you plan around it.
| Your situation | What to use |
|---|---|
| A fixed version exists | Autonomous remediation opens the upgrade pull request; Gold Open Source confirms the safe version |
| You need to know today whether you are affected | Gold Open Source lookup and "Am I affected?" checker, free and without login |
| You want vulnerable versions to stop shipping | The Gold scan GitHub Action in CI, plus Safeguard policies and gates |
| Upstream has no fix yet, or will not ship one | A custom Gold request; fork and patch (early access) |
| You want to start clean on new projects | Pull hardened components and images from Gold Registry |
One honest limit: when the vulnerable code lives in a vendor appliance, a hosted service or an operating system runtime you do not build yourself, the fix still has to come from that vendor or distribution. The steps above apply to the open source dependencies and container images you build and ship, which is where most teams carry the largest and least tracked share of their exposure.
See how Safeguard would help
None of these demos shows an MCP task. Each shows a Safeguard surface that uses a control this post recommends: a person choosing which tools an assistant gets, explicit approval before each action, and approvals scoped to one request. You can download Safeguard and follow the CLI documentation to try them.
This demo connects Claude to the Safeguard MCP server with OAuth and lets you choose which tools Claude can use. In everyday use, add Safeguard from Claude's connector directory rather than by URL; the download page says that route also uses a one-time OAuth sign-in. Scoping tools per assistant is the first line of defence for long-running tools too, since a tool the model cannot call cannot start a task.
Click through it at your own pace, or open it full screen.
This is a real CLI session. Asked to fix a lodash CVE, the CLI reads the finding and advisory, asks before each command, edits package.json to lodash 4.17.21, runs npm install and npm test with approval, prints a summary and pushes nothing, then shows /permissions, /model and /scan .. It is the pattern this post asks for when a task result arrives: read it, then decide, with a human approving each action that follows.
Click through it at your own pace, or open it full screen.
This real Android session sends work to a computer running the CLI and approves the command on the phone with Allow once, Allow for turn or Deny. Long-running work often finishes when you are away from your desk; approving the next step from a phone, one command at a time, is the same short-lived, scoped approval this post recommends for input_required prompts.
Click through it at your own pace, or open it full screen.
Quick check
A 2026-07-28 MCP server acknowledges your tasks/cancel request. Is the work guaranteed to have stopped?
No. In the Tasks extension cancellation is cooperative. The server must acknowledge, but it is not obliged to stop the work, the status may stay working for a while, and the task may still end as completed or failed.
Why did the 2026-07-28 Tasks extension remove tasks/list?
Because it could not be scoped safely. Without protocol sessions and without a reliable authorization context on every server, a list could reveal one caller's tasks to another. SEP-2663 lists the removal as a security improvement over 2025-11-25.
Your server has no authentication and supports 2025-11-25 tasks. What does the spec ask you to do?
Generate cryptographically secure task IDs with enough entropy to prevent guessing, consider shorter TTLs, document clearly that results may be readable by anyone with the ID, and do not declare the tasks.list capability.
Sources
- Model Context Protocol, Tasks (2025-11-25)
- Model Context Protocol, Key Changes (2025-11-25 changelog)
- Model Context Protocol, Progress (2025-11-25)
- Model Context Protocol, Cancellation (2025-11-25)
- Model Context Protocol, Security Best Practices (2025-11-25)
- Model Context Protocol, Specification 2026-07-28
- Model Context Protocol, Key Changes (2026-07-28 changelog)
- Model Context Protocol, Versioning
- Model Context Protocol, Multi Round-Trip Requests (2026-07-28)
- Model Context Protocol, Tasks extension overview
- Model Context Protocol, Extensions overview
- Model Context Protocol, Extension support matrix
- modelcontextprotocol/ext-tasks on GitHub
- ext-tasks, Tasks specification 2026-07-28
- ext-tasks, SEP-2663: Tasks Extension
- ext-tasks, SEP-1686: Tasks
- SEP-1686 issue on GitHub
- SEP-2663 pull request on GitHub
- @modelcontextprotocol/ext-tasks package README
- MCP TypeScript SDK on GitHub
- MCP TypeScript SDK v1.x, experimental tasks
- MCP TypeScript SDK v1.x, interactive task example
- MCP TypeScript SDK v1.x, task store session scope changeset
- MCP Python SDK on GitHub
- MCP Python SDK, tasks example README
- Agent2Agent (A2A) Protocol Specification
- RFC 9110, HTTP Semantics
- OWASP Gen AI Security Project, LLM01:2025 Prompt Injection
- OWASP Top 10 for Large Language Model Applications
- OWASP Insecure Direct Object Reference Prevention Cheat Sheet
- Safeguard download page
Last reviewed: 24 October 2026. MCP tasks are still changing; check the specification and the ext-tasks repository for the latest status.