SEP-2339: Task Continuity#2339
SEP-2339: Task Continuity#2339LucaButBoring wants to merge 6 commits intomodelcontextprotocol:mainfrom
Conversation
9655c59 to
517b7a9
Compare
markdroth
left a comment
There was a problem hiding this comment.
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_. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
@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.
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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`). |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
But, in the current tasks protocol, my server implementation moves to
input_requiredand 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 atinput_requiredas 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.
Add result/error fields to Task interface, introduce tasks/continue method replacing tasks/result, and update specification documentation to reflect the new task lifecycle.
420a4b4 to
d10d116
Compare
| <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> |
There was a problem hiding this comment.
@Randgalt - I just wrote up a full flow with a task-augmented tool call using elicitation under MRTR here, for reference
There was a problem hiding this comment.
Thank you - very helpful. I added a few comments/questions
| } | ||
| } | ||
| ``` | ||
|
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Yes to both questions.
| "id": 4, | ||
| "jsonrpc": "2.0", | ||
| "result": { | ||
| "inputRequests": { |
There was a problem hiding this comment.
inputRequests is plural but it seems there can only be 1 outstanding request right?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
To resolve existing ambiguity around the
input_requiredandtasks/resultflows for tasks, and to accommodate SEP-2322 Multi Round-Trip Requests, this SEP introduces a consolidatedtasks/continuemethod that absorbs the responsibilities of the entire task-polling lifecycle into a single method and inlines the final result/error intotasks/get. This SEP then removes the associated requirements aroundinput_requiredand removestasks/resultto simplify implementations.Motivation and Context
Tasks were introduced in an experimental state in the
2025-11-25specification 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_requiredstatus transition:tasks/resultis unintuitive and it only done to accommodate the possibility of no other SSE streams being open in Streamable HTTP.tasks/resultblocks until completion is even less intuitive.completedstatus just to retrieve the final task result.completedstatus after this point, the server needs to identify all opentasks/resultrequests 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/resultfor 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
Checklist
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.