Skip to content

SEP-2339: Task Continuity#2339

Closed
LucaButBoring wants to merge 4 commits into
modelcontextprotocol:mainfrom
LucaButBoring:feat/task-continuity
Closed

SEP-2339: Task Continuity#2339
LucaButBoring wants to merge 4 commits into
modelcontextprotocol:mainfrom
LucaButBoring:feat/task-continuity

Conversation

@LucaButBoring

@LucaButBoring LucaButBoring commented Mar 3, 2026

Copy link
Copy Markdown
Contributor

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
@LucaButBoring LucaButBoring force-pushed the feat/task-continuity branch from 9655c59 to 517b7a9 Compare March 3, 2026 00:52

@markdroth markdroth left a comment

Copy link
Copy Markdown
Contributor

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
Contributor

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.

@LucaButBoring LucaButBoring Mar 3, 2026

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.

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
Contributor

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
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
Contributor

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

+1 to what Mark is saying I think combining all operations into one method tasks/continue, and the server always sends the status & any current server requests.

we do need a way to send that a client rejected a response regardless to complete the loop because a Task may need to fail if a client refuses to provide additional info.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@CaitieM20 Do we actually need a special way to say that the client is rejecting the input request? I had assumed that the task wouldn't be able to be completed in that case, so the client would just cancel the task.

If we think that there are use-cases where the original request might be able to make progress without getting a response to the elicitation, we should consider how to handle that as part of the MRTR SEP. For example, we could have an input response that contains an error. We might also want to consider adding an "isOptional" field to the input request, so the client can tell whether the input request is actually required or not.

In any case, I think this is probably something to discuss in the MRTR SEP rather than here.

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.

Not only can the client cancel the task, the task does still have a TTL (unless the server explicitly chooses otherwise), meaning that even if the client does not cancel the task, it will gracefully get cleaned-up eventually regardless.

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`).

@Randgalt Randgalt Mar 3, 2026

Copy link
Copy Markdown

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.

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?

@Randgalt Randgalt Mar 6, 2026

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 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.

@Randgalt Randgalt Mar 6, 2026

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 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.

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

Copy link
Copy Markdown
Contributor Author

Just spoke with @CaitieM20 - I'll be superseding this and #2339 with a single proposal describing the changes to tasks in the next specification version, to avoid needing to reason about the implications of different sets of changes interacting with each other all at once. That will also incorporate the sampling/elicitation changes that #2260 brought in as well, just to keep everything in one place.

@LucaButBoring

Copy link
Copy Markdown
Contributor Author

Superseded by #2557; closing.

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.

5 participants