Skip to content

SEP-2339: Task Continuity#2339

Open
LucaButBoring wants to merge 6 commits intomodelcontextprotocol:mainfrom
LucaButBoring:feat/task-continuity
Open

SEP-2339: Task Continuity#2339
LucaButBoring wants to merge 6 commits intomodelcontextprotocol:mainfrom
LucaButBoring:feat/task-continuity

Conversation

@LucaButBoring
Copy link
Copy Markdown
Contributor

@LucaButBoring LucaButBoring commented Mar 3, 2026

To resolve existing ambiguity around the input_required and tasks/result flows for tasks, and to accommodate SEP-2322 Multi Round-Trip Requests, this SEP introduces a consolidated tasks/continue method that absorbs the responsibilities of the entire task-polling lifecycle into a single method and inlines the final result/error into tasks/get. This SEP then removes the associated requirements around input_required and removes tasks/result to simplify implementations.

Motivation and Context

Tasks were introduced in an experimental state in the 2025-11-25 specification release, serving as an alternate execution mode for certain request types (tool calls, elicitation, and sampling) to enable polling for the result of a task-augmented operation.

The task-polling flow as currently defined has several problems, most of which are related to the input_required status transition:

  1. Prematurely invoking tasks/result is unintuitive and it only done to accommodate the possibility of no other SSE streams being open in Streamable HTTP.
  2. The fact that tasks/result blocks until completion is even less intuitive.
  3. Clients need to issue an additional request after encountering the completed status just to retrieve the final task result.
  4. When a task reaches the completed status after this point, the server needs to identify all open tasks/result requests for that task to appropriately close them with the final task result, introducing unnecessary architectural complexity by mandating some sort of internal push-based messaging, which defies the intent of tasks' polling-based design.

Furthermore, in #2322/modelcontextprotocol/transports-wg#12, we identified that we would need to make a breaking change to tasks/result for that anyways, but that redesigning the flow properly would be a scope expansion that would derail MRTR discussion. Regardless, MRTR relies heavily on tasks as a solution for "persistent" requests that require server-side state, so these two proposals are somewhat interdependent. #2322 will be adjusted to follow the accepted redesign from this SEP, whatever that happens to look like.

How Has This Been Tested?

TBD

Breaking Changes

Yes, this removes tasks/result.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

AI Use Disclosure: The core SEP document was written entirely by me, but the actual specification and schema changes were written with Claude Code. The LLM-annotated diff validating the specification and schema changes against the SEP requirements is available here.

@LucaButBoring LucaButBoring changed the title SEP-XXXX: Task Continuity SEP-2339: Task Continuity Mar 3, 2026
Copy link
Copy Markdown

@markdroth markdroth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for writing this up, Luca! I think this is a really helpful change: it eliminates a lot of unnecessary round trips and makes the protocol much easier to understand.


If we're introducing a new method that encapsulates the entire task-polling lifecycle, **why should we keep `tasks/get`**?

While `tasks/continue` owns the full task-polling lifecycle, `tasks/get` still owns single-task state lookups. This is particularly relevant under MRTR (SEP-2322), as this method becomes the only way to fetch the state of a task _without first being expected to handle input requests_.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we actually need to fetch the state of a task without seeing the input requests as part of MRTR? It doesn't seem harmful to send the input requests even if the client doesn't deal with them right away.

An alternative here would be to say that the tasks/continue response will always include the task status, and if the task state is input_required, then it will also include the input requests. If the client doesn't want to deal with the input requests immediately, that's fine -- they will still be there the next time the client calls tasks/continue.

Copy link
Copy Markdown
Contributor Author

@LucaButBoring LucaButBoring Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could technically do that and also remove tasks/get, but that then means needing a carveout for how MRTR rejections are parsed and handled specifically for tasks. That seems undesirable from an SDK standpoint as it means needing logic that says "this response is both a normal acceptable response and an IncompleteResult, and that data flow will just get mapped to all the channels it needs to go to... somehow."

There's no consistent early-return case if you sometimes need to handle IncompleteResult before returning to the caller (all ephemeral messages) and you sometimes need to handle it after returning to the caller (tasks/persistent messages). It's the same sort of bind the existing tasks spec has with SSE side-channeling, where it requires handling the same tasks/result message in two parallel ways (side-channeling messages and returning the final result) - it works, but it's just not intuitive to implement unless you design the SDK around it fundamentally.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@markdroth makes a good point. As someone who is writing an MCP server I can tell you that the server is a state machine. So, why not have one method: tasks/state that returns that state - i.e. the entire state. tasks/get plus tasks/continue requires an unnecessary extra round trip.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tasks/get isn't a required part of the polling flow with this change, so it's not really an unnecessary round-trip in the common use case. This is more so for e.g. UI use cases, like I mentioned. It'd be nice to merge them further, all else being equal, but the cost would be non-uniform MRTR logic for this one method — would appreciate second opinions from folks who have implemented tasks in other SDKs first before recommending that (@maxisbey maybe? @mikekistler?). IMO, protocol logic which is different for a subset of call paths are where we encounter the most bugs (tasks, pings, init, background streams).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we'd use IncompleteResult in this case anyway. IncompleteResult is specifically for the MRTR ephemeral workflow, but tasks are used only for the persistent workflow. An IncompleteResult contains both input requests and request state, but here we need only input requests -- request state is specific to the ephemeral workflow, because it doesn't really apply to the persistent workflow.

Given that, it's not clear to me that there's actually a conflict here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be clear, what I'm suggesting is that the tasks/continue result would just include an optional inputRequests field that would be populated when the task is in input_required status, just like it has an optional result field when the task reaches completed status. I think this yields a more consistent behavior for tasks/continue across all statuses, and it avoids the problem of allowing requestStatus in the persistent workflow.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose that at that point we could reduce it all into one method for everything, other than the minor issue of naming getting challenging (either passing responses to a tasks/get method or doing a boring state read with tasks/continue). That sounds workable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It still does create bifurcation on the client side with those requests needing to be worked into the message flow at two different levels, but I guess this does sidestep the larger issue via making that bifurcation more explicit.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It sounds like we're converging, but just to try to make you feel a bit better about it:

I don't really think of this as bifurcation; I think of it simply as reusing the message types that actually make sense to reuse. The message that's actually common to both the ephemeral and persistent workflows is InputRequests, not IncompleteResult. And to be fair, IncompleteResult is really just InputRequests plus a string field, so most of the actual interesting structure is inside InputRequests anyway, and that's the part we're going to reuse.

1. When the task receiver has messages for the requestor that are necessary to complete the task, the receiver **SHOULD** move the task to the `input_required` status.
1. The receiver **MUST** include the `io.modelcontextprotocol/related-task` metadata in the request to associate it with the task.
- 1. When the requestor encounters the `input_required` status, it **SHOULD** preemptively call `tasks/result`.
1. When the receiver receives all required input, the task **SHOULD** transition out of `input_required` status (typically back to `working`).
Copy link
Copy Markdown

@Randgalt Randgalt Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the task stays in input_required until the response is received? This is something that was difficult in the current protocol. I hope the answer is yes. In other words, when the task needs input, it returns status input_required and stays in that state until the requestor sets the response to the required input?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The task status will still change immediately after the receiver accepts the response, whenever that happens to be. I think the answer to your question is "yes" but I might need an example.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know how this PR interacts with input_required and/or the MRTR PR. Examples will help. But, in the current tasks protocol, my server implementation moves to input_required and stays there until the requestor posts the reply. i.e. even after they get the task "response" (which is really a request) that task stays at input_required as there is no other reasonable state to use.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just read most of the MRTR docs. An example of a complete task: request, elicitation-request, elicitation-response, final-tool-response would be really helpful if possible to do.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But, in the current tasks protocol, my server implementation moves to input_required and stays there until the requestor posts the reply. i.e. even after they get the task "response" (which is really a request) that task stays at input_required as there is no other reasonable state to use.

Ah, yes - this is still the expected behavior. I'll add examples to this elaborating on the MRTR flow.

LucaButBoring and others added 3 commits March 5, 2026 15:06
Add result/error fields to Task interface, introduce tasks/continue
method replacing tasks/result, and update specification documentation
to reflect the new task lifecycle.
@LucaButBoring LucaButBoring force-pushed the feat/task-continuity branch from 420a4b4 to d10d116 Compare March 5, 2026 23:59
Comment on lines +241 to +424
<details>

Consider a simple task-augmented tool call, `hello_world`, requiring an elicitation for the user to provide their name. The tool itself takes no arguments.

To invoke this tool, the client makes a `CallToolRequest` as follows:

```json
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "hello_world",
"arguments": {}
},
"task": {
"ttl": 30000
}
}
```

The server recognizes this as a task-augmented request and immediately returns a `CreateTaskResult`:

```json
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"task": {
"taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
"status": "working",
"createdAt": "2025-11-25T10:30:00Z",
"lastUpdatedAt": "2025-11-25T10:50:00Z",
"ttl": 30000,
"pollInterval": 5000
}
}
}
```

Once the client receives the `CreateTaskResult`, it begins polling `tasks/continue`:

```json
{
"jsonrpc": "2.0",
"id": 3,
"method": "tasks/continue",
"params": {
"taskId": "786512e2-9e0d-44bd-8f29-789f320fe840"
}
}
```

On each request while the task is in a `"working"` status, the server returns a regular task response:

```json
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
"status": "working",
"createdAt": "2025-11-25T10:30:00Z",
"lastUpdatedAt": "2025-11-25T10:50:00Z",
"ttl": 30000,
"pollInterval": 5000
}
}
```

Eventually, the server reaches the point at which it needs to send an elicitation to the user. It sets the task status to `"input_required"` to signal this. On the next `tasks/continue` request from the client, the server sends the elicitation payload via an `IncompleteResult`, following standard MRTR semantics. Note that the task info is not present in this response, which conforms to typical behavior under MRTR. If the client were to send a `tasks/get` request during this time, it would see a regular status response with `"input_required"` (without the embedded `inputRequests`).

```json
{
"jsonrpc": "2.0",
"id": 4,
"method": "tasks/continue",
"params": {
"taskId": "786512e2-9e0d-44bd-8f29-789f320fe840"
}
}
```

```json
{
"id": 4,
"jsonrpc": "2.0",
"result": {
"inputRequests": {
"name": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "Please enter your name.",
"requestedSchema": {
"type": "object",
"properties": {
"name": { "type": "string" }
},
"required": ["name"]
}
}
}
}
}
}
```

The user enters their name, and the client repeats the `tasks/continue` request with the satisfied information:

```json
{
"jsonrpc": "2.0",
"id": 5,
"method": "tasks/continue",
"params": {
"taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
"inputResponses": {
"name": {
"action": "accept",
"content": {
"input": "Luca"
}
}
}
}
}
```

With the elicitation fulfilled and no other outstanding requests to send, the server moves the task back into the `"working"` status:

```json
{
"jsonrpc": "2.0",
"id": 5,
"result": {
"taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
"status": "working",
"createdAt": "2025-11-25T10:30:00Z",
"lastUpdatedAt": "2025-11-25T10:50:00Z",
"ttl": 30000,
"pollInterval": 5000
}
}
```

Eventually, the server completes the request, so it stores the final `CallToolResult` and moves the task into the `"completed"` status. On the next `tasks/continue` request, the server sends the final tool result inlined into the task object:

```json
{
"jsonrpc": "2.0",
"id": 6,
"method": "tasks/continue",
"params": {
"taskId": "786512e2-9e0d-44bd-8f29-789f320fe840"
}
}
```

```json
{
"jsonrpc": "2.0",
"id": 6,
"result": {
"taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
"status": "completed",
"createdAt": "2025-11-25T10:30:00Z",
"lastUpdatedAt": "2025-11-25T10:50:00Z",
"ttl": 30000,
"pollInterval": 5000,
"result": {
"content": [
{
"type": "text",
"text": "Hello, Luca!"
}
],
"isError": false
}
}
}
```

</details>
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Randgalt - I just wrote up a full flow with a task-augmented tool call using elicitation under MRTR here, for reference

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you - very helpful. I added a few comments/questions

@LucaButBoring LucaButBoring marked this pull request as ready for review March 6, 2026 00:06
@LucaButBoring LucaButBoring requested review from a team as code owners March 6, 2026 00:06
}
}
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a tasks/get is called at this point, I'd expect the server to still return input_required correct? i.e. tasks/get will return input_required until the response is received.

Additionally, if tasks/continue were called again here for some reason, the response would be exactly the same inputRequests right?

What I'm getting is what state does the server need to keep and when can it clear that state.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes to both questions.

"id": 4,
"jsonrpc": "2.0",
"result": {
"inputRequests": {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inputRequests is plural but it seems there can only be 1 outstanding request right?

Copy link
Copy Markdown

@Randgalt Randgalt Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there can be multiple outstanding input requests then a request ID is needed. tbh - I suggest adding an input request ID regardless. It will make writing the server code a lot easier and will be useful if, in the future, multiple/simultaneous input requests are to be allowed.

Copy link
Copy Markdown

@Randgalt Randgalt Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just re-read the MRTR doc. I guess the "name" serves as an ID? Multiple input requests are represented by unique keys in that object right?

FWIW - with MRTR I wonder how useful tasks are? MRTR is, essentially, a lightweight task framework right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inputRequests is a map, with each pending request getting a unique key within that particular request.

With MRTR, Tasks handle the case where server state is acceptable so that we don't need to restart potentially-heavy work after an MRTR rejection, e.g. if I have some external service running a workflow and need an elicitation in the middle of that, I don't want the response to then dispatch a new workflow all over again.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To clarify, the default assumption in MRTR ("ephemeral" requests) is that every time the server sends something, the entire request state is thrown out, so you can scale trivially. With tasks in MRTR ("persistent" requests), we reuse the same message flow but have the task as a record/handle to some persistent server state.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

proposal SEP proposal without a sponsor. SEP

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

4 participants