-
Notifications
You must be signed in to change notification settings - Fork 711
Multi Round-Trip Requests (MRTR) #1458
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+5,832
−103
Merged
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
fb78b76
Rebase MRTR onto main (squash merge with conflict resolution)
halter73 5ac82bc
Apply SEP-2322 spec renames and remove ExperimentalProtocolVersion
halter73 8205a0f
Refine MRTR gating and align tests with new protocol model
halter73 3020412
Collapse AspNetCore MRTR test matrix to single ProtocolVersion axis
halter73 8f87ad4
Add SEP-2322 MRTR conformance scenarios (ephemeral)
halter73 18c0df7
Remove implicit MRTR machinery and require InputRequiredException und…
halter73 75fe8ee
Address review feedback: drop typed InputResponse accessors and resol…
halter73 5db1781
Gate high-level server-to-client requests on stateless mode, not draf…
halter73 a8c60d0
Pre-undraft cleanup: revert unrelated noise, drop low-level/high-leve…
halter73 6148b43
Remove stale 'Deferred Task Creation with MRTR' section from tasks.md…
halter73 c2467d3
Increase test hang timeout to stabilize debug CI job
Copilot e1e81e5
Route MRTR backcompat resolver requests through destination-bound tra…
halter73 17dc39c
Revert blame-hang-timeout increase
halter73 5bf84d2
Add raw-HTTP regression test for MRTR backcompat resolver routing
halter73 b0f1e30
Address tarekgh PR review feedback
halter73 ff6372a
Restore implicit MRTR machinery for stateful sessions
halter73 17a4f08
Merge origin/main (#1603 rename, #1517 resource URI, #1604 DELETE auth)
halter73 ef6d853
Strip stale requestState across MRTR retry rounds (#1458 review)
halter73 dcdd00b
Remove MRTR Core tests redundant with MapMcpTests theory rows
halter73 877ce83
Address June 3 MRTR review feedback (#1458)
halter73 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,291 @@ | ||
| --- | ||
| title: Multi Round-Trip Requests (MRTR) | ||
| author: halter73 | ||
| description: How servers request client input during tool execution using Multi Round-Trip Requests. | ||
| uid: mrtr | ||
| --- | ||
|
|
||
| # Multi Round-Trip Requests (MRTR) | ||
|
|
||
| <!-- mlc-disable-next-line --> | ||
| > [!WARNING] | ||
| > MRTR is part of the **`DRAFT-2026-v1`** revision of the MCP specification ([SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322)). The wire format and API surface may change before the revision is ratified. See the [Experimental APIs](../../experimental.md) documentation for details on working with experimental APIs. | ||
|
|
||
| Multi Round-Trip Requests (MRTR) let a server tool request input from the client — such as [elicitation](xref:elicitation), [sampling](xref:sampling), or [roots](xref:roots) — as part of a single tool call, without requiring a separate server-to-client JSON-RPC request for each interaction. Instead of returning a final result, the server returns an **incomplete result** containing one or more input requests. The client fulfills those requests and retries the original tool call with the responses attached. | ||
|
|
||
| ## Overview | ||
|
|
||
| MRTR is useful when: | ||
|
|
||
| - A tool needs user confirmation before proceeding (elicitation). | ||
| - A tool needs LLM reasoning from the client (sampling). | ||
| - A tool needs an updated list of client roots. | ||
| - A tool needs to perform multiple rounds of interaction in a single logical operation. | ||
| - A stateless server needs to orchestrate multi-step flows without keeping handler state in memory between rounds. | ||
|
|
||
| ## How MRTR works | ||
|
|
||
| 1. The client calls a tool on the server via `tools/call`. | ||
| 2. The server tool determines it needs client input and returns an `InputRequiredResult` containing `inputRequests` and/or `requestState`. | ||
| 3. The client resolves each input request (for example by prompting the user for elicitation, calling an LLM for sampling, or listing its roots). | ||
| 4. The client retries the original `tools/call` with `inputResponses` (keyed to the input requests) and `requestState` echoed back. | ||
| 5. The server processes the responses and either returns a final result or another `InputRequiredResult` for additional rounds. | ||
|
|
||
| ## Opting in | ||
|
|
||
| MRTR activates when both peers negotiate protocol revision **`DRAFT-2026-v1`** during `initialize`. The C# SDK opts in by listing `DRAFT-2026-v1` as a supported protocol version on the client; servers automatically accept it when offered. No experimental flags are required. | ||
|
|
||
| ```csharp | ||
| // Client | ||
| var clientOptions = new McpClientOptions | ||
| { | ||
| ProtocolVersion = "DRAFT-2026-v1", | ||
| Handlers = new McpClientHandlers | ||
| { | ||
| ElicitationHandler = HandleElicitationAsync, | ||
| SamplingHandler = HandleSamplingAsync, | ||
| } | ||
| }; | ||
| ``` | ||
|
|
||
| Under `DRAFT-2026-v1`, MRTR is the recommended way to obtain client input from a server handler. The spec removes the legacy server-to-client `elicitation/create`, `sampling/createMessage`, and `roots/list` request methods, so any code that needs to work on a `DRAFT-2026-v1` Streamable HTTP server (which will be stateless-only in a future revision) must use `InputRequiredException` rather than <xref:ModelContextProtocol.Server.McpServer.ElicitAsync*>, <xref:ModelContextProtocol.Server.McpServer.SampleAsync*>, or <xref:ModelContextProtocol.Server.McpServer.RequestRootsAsync*>. The legacy methods still work on stateful sessions — that's how stdio servers keep working under draft today — but they throw `InvalidOperationException("X is not supported in stateless mode.")` on any stateless session, current or draft. | ||
|
|
||
| Under the current protocol revision (`2025-06-18` and earlier), `InputRequiredException` is still supported in stateful sessions via a backward-compatibility resolver — see [Compatibility](#compatibility) below. | ||
|
|
||
| ## Authoring an MRTR tool | ||
|
|
||
| A tool participates in MRTR by throwing <xref:ModelContextProtocol.Protocol.InputRequiredException> with an <xref:ModelContextProtocol.Protocol.InputRequiredResult> describing what it needs. On retry, the client's responses arrive on the request parameters and the tool inspects them to decide what to do next. | ||
|
|
||
| ### Checking MRTR support | ||
|
|
||
| Tools should check <xref:ModelContextProtocol.Server.McpServer.IsMrtrSupported> before throwing `InputRequiredException`. It returns `true` when either: | ||
|
|
||
| - The negotiated protocol revision is `DRAFT-2026-v1` (MRTR is native), or | ||
| - The session is stateful under the current protocol (the SDK can resolve input requests via legacy JSON-RPC and retry the handler). | ||
|
|
||
| ```csharp | ||
| [McpServerTool, Description("A tool that uses MRTR")] | ||
| public static string MyTool( | ||
| McpServer server, | ||
| RequestContext<CallToolRequestParams> context) | ||
| { | ||
| if (!server.IsMrtrSupported) | ||
| { | ||
| return "This tool requires a client that negotiates DRAFT-2026-v1, " | ||
| + "or a stateful current-protocol session."; | ||
| } | ||
|
|
||
| // ... MRTR logic | ||
| } | ||
| ``` | ||
|
|
||
| ### Returning an incomplete result | ||
|
|
||
| Throw <xref:ModelContextProtocol.Protocol.InputRequiredException> to return an incomplete result. The exception carries an <xref:ModelContextProtocol.Protocol.InputRequiredResult> containing `inputRequests` and/or `requestState`: | ||
|
|
||
| ```csharp | ||
| [McpServerTool, Description("Tool managing its own MRTR flow")] | ||
| public static string AnswerTool( | ||
| McpServer server, | ||
| RequestContext<CallToolRequestParams> context, | ||
| [Description("The user's question")] string question) | ||
| { | ||
| var requestState = context.Params!.RequestState; | ||
| var inputResponses = context.Params!.InputResponses; | ||
|
|
||
| // On retry, process the client's responses | ||
| if (requestState is not null && inputResponses is not null) | ||
| { | ||
| var elicitResult = inputResponses["user_answer"].Deserialize(InputResponse.ElicitResultJsonTypeInfo); | ||
| return $"You answered: {elicitResult?.Content?.FirstOrDefault().Value}"; | ||
| } | ||
|
|
||
| if (!server.IsMrtrSupported) | ||
| { | ||
| return "MRTR is not supported by this client."; | ||
| } | ||
|
|
||
| // First call — request user input | ||
| throw new InputRequiredException( | ||
| inputRequests: new Dictionary<string, InputRequest> | ||
| { | ||
| ["user_answer"] = InputRequest.ForElicitation(new ElicitRequestParams | ||
| { | ||
| Message = $"Please answer: {question}", | ||
| RequestedSchema = new() | ||
| { | ||
| Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition> | ||
| { | ||
| ["answer"] = new ElicitRequestParams.StringSchema | ||
| { | ||
| Description = "Your answer" | ||
| } | ||
| } | ||
| } | ||
| }) | ||
| }, | ||
| requestState: "awaiting-answer"); | ||
| } | ||
| ``` | ||
|
|
||
| ### Accessing retry data | ||
|
|
||
| When the client retries a tool call, the retry data is available on the request parameters: | ||
|
|
||
| - <xref:ModelContextProtocol.Protocol.RequestParams.InputResponses> — a dictionary of client responses keyed by the same keys used in `inputRequests`. | ||
| - <xref:ModelContextProtocol.Protocol.RequestParams.RequestState> — the opaque state string echoed back by the client. | ||
|
|
||
| Use <xref:ModelContextProtocol.Protocol.InputResponse.Deserialize*> with the `JsonTypeInfo<T>` matching the response type. The expected type follows from the matching <xref:ModelContextProtocol.Protocol.InputRequest.Method> in the original `inputRequests` map — there is no on-the-wire discriminator. | ||
|
|
||
| - Elicitation — `response.Deserialize(InputResponse.ElicitResultJsonTypeInfo)` | ||
| - Sampling — `response.Deserialize(InputResponse.CreateMessageResultJsonTypeInfo)` | ||
| - Roots list — `response.Deserialize(InputResponse.ListRootsResultJsonTypeInfo)` | ||
|
|
||
| ### Load shedding with requestState-only responses | ||
|
|
||
| A server can return a `requestState`-only incomplete result (without any `inputRequests`) to defer processing. This is useful for load shedding or breaking up long-running work across multiple requests: | ||
|
|
||
| ```csharp | ||
| [McpServerTool, Description("Tool that defers work using requestState")] | ||
| public static string DeferredTool( | ||
| McpServer server, | ||
| RequestContext<CallToolRequestParams> context) | ||
| { | ||
| var requestState = context.Params!.RequestState; | ||
|
|
||
| if (requestState is not null) | ||
| { | ||
| // Resume deferred work | ||
| var state = JsonSerializer.Deserialize<MyState>( | ||
| Convert.FromBase64String(requestState)); | ||
| return $"Completed step {state!.Step}"; | ||
| } | ||
|
|
||
| if (!server.IsMrtrSupported) | ||
| { | ||
| return "MRTR is not supported by this client."; | ||
| } | ||
|
|
||
| // Defer work to a later retry | ||
| var initialState = new MyState { Step = 1 }; | ||
| throw new InputRequiredException( | ||
| requestState: Convert.ToBase64String( | ||
| JsonSerializer.SerializeToUtf8Bytes(initialState))); | ||
| } | ||
| ``` | ||
|
|
||
| The client automatically retries `requestState`-only incomplete results, echoing the state back without needing to resolve any input requests. | ||
|
|
||
| ### Multiple round trips | ||
|
|
||
| A tool can perform multiple rounds of interaction by throwing `InputRequiredException` multiple times across retries. Use `requestState` to track which round you're on: | ||
|
|
||
| ```csharp | ||
| [McpServerTool, Description("Multi-step wizard")] | ||
| public static string WizardTool( | ||
| McpServer server, | ||
| RequestContext<CallToolRequestParams> context) | ||
| { | ||
| var requestState = context.Params!.RequestState; | ||
| var inputResponses = context.Params!.InputResponses; | ||
|
|
||
| if (requestState == "step-2" && inputResponses is not null) | ||
| { | ||
| var name = inputResponses["name"].Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Content?.FirstOrDefault().Value; | ||
| var age = inputResponses["age"].Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Content?.FirstOrDefault().Value; | ||
| return $"Welcome, {name}! You are {age} years old."; | ||
| } | ||
|
|
||
| if (requestState == "step-1" && inputResponses is not null) | ||
| { | ||
| var name = inputResponses["name"].Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Content?.FirstOrDefault().Value; | ||
|
|
||
| // Second round — ask for age | ||
| throw new InputRequiredException( | ||
| inputRequests: new Dictionary<string, InputRequest> | ||
| { | ||
| ["age"] = InputRequest.ForElicitation(new ElicitRequestParams | ||
| { | ||
| Message = $"Hi {name}! How old are you?", | ||
| RequestedSchema = new() | ||
| { | ||
| Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition> | ||
| { | ||
| ["age"] = new ElicitRequestParams.NumberSchema | ||
| { | ||
| Description = "Your age" | ||
| } | ||
| } | ||
| } | ||
| }) | ||
| }, | ||
| requestState: "step-2"); | ||
| } | ||
|
|
||
| if (!server.IsMrtrSupported) | ||
| { | ||
| return "MRTR is not supported. Please use a compatible client."; | ||
| } | ||
|
|
||
| // First round — ask for name | ||
| throw new InputRequiredException( | ||
| inputRequests: new Dictionary<string, InputRequest> | ||
| { | ||
| ["name"] = InputRequest.ForElicitation(new ElicitRequestParams | ||
| { | ||
| Message = "What's your name?", | ||
| RequestedSchema = new() | ||
| { | ||
| Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition> | ||
| { | ||
| ["name"] = new ElicitRequestParams.StringSchema | ||
| { | ||
| Description = "Your name" | ||
| } | ||
| } | ||
| } | ||
| }) | ||
| }, | ||
| requestState: "step-1"); | ||
| } | ||
| ``` | ||
|
|
||
| ### Providing custom error messages | ||
|
|
||
| When MRTR is not supported, you can provide domain-specific guidance: | ||
|
|
||
| ```csharp | ||
| if (!server.IsMrtrSupported) | ||
| { | ||
| return "This tool requires interactive input. To use it:\n" | ||
| + "1. Connect with a client that negotiates MCP protocol revision DRAFT-2026-v1, or\n" | ||
| + "2. Use a stateful current-protocol session so the server can resolve the input requests for you.\n" | ||
| + "\nStateless current-protocol sessions cannot resolve MRTR input requests."; | ||
| } | ||
| ``` | ||
|
|
||
| ## Compatibility | ||
|
|
||
| The SDK supports `InputRequiredException` across two protocol revisions and two session modes: | ||
|
|
||
| | Negotiated protocol | Session mode | Behavior | | ||
| |---|---|---| | ||
| | `DRAFT-2026-v1` | Stateful | Native MRTR — `InputRequiredResult` is serialized directly to the wire. | | ||
| | `DRAFT-2026-v1` | Stateless | Native MRTR — `InputRequiredResult` is serialized directly to the wire. No server-side handler state needed. | | ||
| | Current (`2025-06-18` and earlier) | Stateful | Backward-compatibility resolver — the SDK sends standard `elicitation/create` / `sampling/createMessage` / `roots/list` JSON-RPC requests to the client, collects the responses, and retries the handler with `inputResponses` populated. Up to 10 retry rounds. | | ||
| | Current (`2025-06-18` and earlier) | Stateless | **Not supported** — `InputRequiredException` raises an `McpException`. The client doesn't speak MRTR, and the server can't resolve input requests via JSON-RPC without a persistent session. | | ||
|
|
||
| > [!NOTE] | ||
| > The backcompat resolver is intentionally limited to 10 retry rounds. Tools that need more rounds should require `DRAFT-2026-v1` (check `IsMrtrSupported`). | ||
|
|
||
| ### Why `ElicitAsync` / `SampleAsync` / `RequestRootsAsync` throw on stateless servers | ||
|
|
||
| `ElicitAsync` / `SampleAsync` / `RequestRootsAsync` issue a JSON-RPC request to the client and wait for the response on the same session. Stateless servers don't have a persistent session to wait on, so the SDK fails fast with `InvalidOperationException("X is not supported in stateless mode.")` (the check is `McpServer.ClientCapabilities is null`, which is the SDK's proxy for stateless). | ||
|
|
||
| Under the current protocol revision (`2025-06-18` and earlier), stdio and stateful Streamable HTTP keep `ClientCapabilities` populated, so the legacy methods work normally and remain the recommended way to do one-shot client interactions. Under `DRAFT-2026-v1`, the spec removes those request methods from Streamable HTTP entirely; the SDK still allows the legacy methods on draft stdio sessions because stdio is implicitly single-process / stateful and the client handler is wired up regardless of negotiated revision. `InputRequiredException` is the way to write tools that work on every supported configuration. | ||
|
|
||
| ### Future direction | ||
|
|
||
| The `DRAFT-2026-v1` revision is moving toward a stateless-only model: `Mcp-Session-Id` is being removed, and Streamable HTTP servers will run statelessly by default under the draft revision. When that lands, the `Stateful` row for `DRAFT-2026-v1` in the compatibility matrix above collapses into the `Stateless` row (Streamable HTTP under draft becomes stateless-only), and `InputRequiredException` becomes uniformly required for non-stdio servers. The current-protocol resolver path will remain for backward compatibility with older clients and stateful servers. | ||
|
|
||
| This work is a follow-up to the present PR. |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.