|
| 1 | +--- |
| 2 | +title: Multi Round-Trip Requests (MRTR) |
| 3 | +author: halter73 |
| 4 | +description: How servers request client input during tool execution using Multi Round-Trip Requests. |
| 5 | +uid: mrtr |
| 6 | +--- |
| 7 | + |
| 8 | +# Multi Round-Trip Requests (MRTR) |
| 9 | + |
| 10 | +<!-- mlc-disable-next-line --> |
| 11 | +> [!WARNING] |
| 12 | +> 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. |
| 13 | +
|
| 14 | +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. |
| 15 | + |
| 16 | +## Overview |
| 17 | + |
| 18 | +MRTR is useful when: |
| 19 | + |
| 20 | +- A tool needs user confirmation before proceeding (elicitation). |
| 21 | +- A tool needs LLM reasoning from the client (sampling). |
| 22 | +- A tool needs an updated list of client roots. |
| 23 | +- A tool needs to perform multiple rounds of interaction in a single logical operation. |
| 24 | +- A stateless server needs to orchestrate multi-step flows without keeping handler state in memory between rounds. |
| 25 | + |
| 26 | +## How MRTR works |
| 27 | + |
| 28 | +1. The client calls a tool on the server via `tools/call`. |
| 29 | +2. The server tool determines it needs client input and returns an `InputRequiredResult` containing `inputRequests` and/or `requestState`. |
| 30 | +3. The client resolves each input request (for example by prompting the user for elicitation, calling an LLM for sampling, or listing its roots). |
| 31 | +4. The client retries the original `tools/call` with `inputResponses` (keyed to the input requests) and `requestState` echoed back. |
| 32 | +5. The server processes the responses and either returns a final result or another `InputRequiredResult` for additional rounds. |
| 33 | + |
| 34 | +## Opting in |
| 35 | + |
| 36 | +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. |
| 37 | + |
| 38 | +```csharp |
| 39 | +// Client |
| 40 | +var clientOptions = new McpClientOptions |
| 41 | +{ |
| 42 | + ProtocolVersion = "DRAFT-2026-v1", |
| 43 | + Handlers = new McpClientHandlers |
| 44 | + { |
| 45 | + ElicitationHandler = HandleElicitationAsync, |
| 46 | + SamplingHandler = HandleSamplingAsync, |
| 47 | + } |
| 48 | +}; |
| 49 | +``` |
| 50 | + |
| 51 | +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. |
| 52 | + |
| 53 | +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. |
| 54 | + |
| 55 | +## Authoring an MRTR tool |
| 56 | + |
| 57 | +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. |
| 58 | + |
| 59 | +### Checking MRTR support |
| 60 | + |
| 61 | +Tools should check <xref:ModelContextProtocol.Server.McpServer.IsMrtrSupported> before throwing `InputRequiredException`. It returns `true` when either: |
| 62 | + |
| 63 | +- The negotiated protocol revision is `DRAFT-2026-v1` (MRTR is native), or |
| 64 | +- The session is stateful under the current protocol (the SDK can resolve input requests via legacy JSON-RPC and retry the handler). |
| 65 | + |
| 66 | +```csharp |
| 67 | +[McpServerTool, Description("A tool that uses MRTR")] |
| 68 | +public static string MyTool( |
| 69 | + McpServer server, |
| 70 | + RequestContext<CallToolRequestParams> context) |
| 71 | +{ |
| 72 | + if (!server.IsMrtrSupported) |
| 73 | + { |
| 74 | + return "This tool requires a client that negotiates DRAFT-2026-v1, " |
| 75 | + + "or a stateful current-protocol session."; |
| 76 | + } |
| 77 | + |
| 78 | + // ... MRTR logic |
| 79 | +} |
| 80 | +``` |
| 81 | + |
| 82 | +### Returning an incomplete result |
| 83 | + |
| 84 | +Throw <xref:ModelContextProtocol.Protocol.InputRequiredException> to return an incomplete result. The exception carries an <xref:ModelContextProtocol.Protocol.InputRequiredResult> containing `inputRequests` and/or `requestState`: |
| 85 | + |
| 86 | +```csharp |
| 87 | +[McpServerTool, Description("Tool managing its own MRTR flow")] |
| 88 | +public static string AnswerTool( |
| 89 | + McpServer server, |
| 90 | + RequestContext<CallToolRequestParams> context, |
| 91 | + [Description("The user's question")] string question) |
| 92 | +{ |
| 93 | + var requestState = context.Params!.RequestState; |
| 94 | + var inputResponses = context.Params!.InputResponses; |
| 95 | + |
| 96 | + // On retry, process the client's responses |
| 97 | + if (requestState is not null && inputResponses is not null) |
| 98 | + { |
| 99 | + var elicitResult = inputResponses["user_answer"].Deserialize(InputResponse.ElicitResultJsonTypeInfo); |
| 100 | + return $"You answered: {elicitResult?.Content?.FirstOrDefault().Value}"; |
| 101 | + } |
| 102 | + |
| 103 | + if (!server.IsMrtrSupported) |
| 104 | + { |
| 105 | + return "MRTR is not supported by this client."; |
| 106 | + } |
| 107 | + |
| 108 | + // First call — request user input |
| 109 | + throw new InputRequiredException( |
| 110 | + inputRequests: new Dictionary<string, InputRequest> |
| 111 | + { |
| 112 | + ["user_answer"] = InputRequest.ForElicitation(new ElicitRequestParams |
| 113 | + { |
| 114 | + Message = $"Please answer: {question}", |
| 115 | + RequestedSchema = new() |
| 116 | + { |
| 117 | + Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition> |
| 118 | + { |
| 119 | + ["answer"] = new ElicitRequestParams.StringSchema |
| 120 | + { |
| 121 | + Description = "Your answer" |
| 122 | + } |
| 123 | + } |
| 124 | + } |
| 125 | + }) |
| 126 | + }, |
| 127 | + requestState: "awaiting-answer"); |
| 128 | +} |
| 129 | +``` |
| 130 | + |
| 131 | +### Accessing retry data |
| 132 | + |
| 133 | +When the client retries a tool call, the retry data is available on the request parameters: |
| 134 | + |
| 135 | +- <xref:ModelContextProtocol.Protocol.RequestParams.InputResponses> — a dictionary of client responses keyed by the same keys used in `inputRequests`. |
| 136 | +- <xref:ModelContextProtocol.Protocol.RequestParams.RequestState> — the opaque state string echoed back by the client. |
| 137 | + |
| 138 | +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. |
| 139 | + |
| 140 | +- Elicitation — `response.Deserialize(InputResponse.ElicitResultJsonTypeInfo)` |
| 141 | +- Sampling — `response.Deserialize(InputResponse.CreateMessageResultJsonTypeInfo)` |
| 142 | +- Roots list — `response.Deserialize(InputResponse.ListRootsResultJsonTypeInfo)` |
| 143 | + |
| 144 | +### Load shedding with requestState-only responses |
| 145 | + |
| 146 | +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: |
| 147 | + |
| 148 | +```csharp |
| 149 | +[McpServerTool, Description("Tool that defers work using requestState")] |
| 150 | +public static string DeferredTool( |
| 151 | + McpServer server, |
| 152 | + RequestContext<CallToolRequestParams> context) |
| 153 | +{ |
| 154 | + var requestState = context.Params!.RequestState; |
| 155 | + |
| 156 | + if (requestState is not null) |
| 157 | + { |
| 158 | + // Resume deferred work |
| 159 | + var state = JsonSerializer.Deserialize<MyState>( |
| 160 | + Convert.FromBase64String(requestState)); |
| 161 | + return $"Completed step {state!.Step}"; |
| 162 | + } |
| 163 | + |
| 164 | + if (!server.IsMrtrSupported) |
| 165 | + { |
| 166 | + return "MRTR is not supported by this client."; |
| 167 | + } |
| 168 | + |
| 169 | + // Defer work to a later retry |
| 170 | + var initialState = new MyState { Step = 1 }; |
| 171 | + throw new InputRequiredException( |
| 172 | + requestState: Convert.ToBase64String( |
| 173 | + JsonSerializer.SerializeToUtf8Bytes(initialState))); |
| 174 | +} |
| 175 | +``` |
| 176 | + |
| 177 | +The client automatically retries `requestState`-only incomplete results, echoing the state back without needing to resolve any input requests. |
| 178 | + |
| 179 | +### Multiple round trips |
| 180 | + |
| 181 | +A tool can perform multiple rounds of interaction by throwing `InputRequiredException` multiple times across retries. Use `requestState` to track which round you're on: |
| 182 | + |
| 183 | +```csharp |
| 184 | +[McpServerTool, Description("Multi-step wizard")] |
| 185 | +public static string WizardTool( |
| 186 | + McpServer server, |
| 187 | + RequestContext<CallToolRequestParams> context) |
| 188 | +{ |
| 189 | + var requestState = context.Params!.RequestState; |
| 190 | + var inputResponses = context.Params!.InputResponses; |
| 191 | + |
| 192 | + if (requestState == "step-2" && inputResponses is not null) |
| 193 | + { |
| 194 | + var name = inputResponses["name"].Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Content?.FirstOrDefault().Value; |
| 195 | + var age = inputResponses["age"].Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Content?.FirstOrDefault().Value; |
| 196 | + return $"Welcome, {name}! You are {age} years old."; |
| 197 | + } |
| 198 | + |
| 199 | + if (requestState == "step-1" && inputResponses is not null) |
| 200 | + { |
| 201 | + var name = inputResponses["name"].Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Content?.FirstOrDefault().Value; |
| 202 | + |
| 203 | + // Second round — ask for age |
| 204 | + throw new InputRequiredException( |
| 205 | + inputRequests: new Dictionary<string, InputRequest> |
| 206 | + { |
| 207 | + ["age"] = InputRequest.ForElicitation(new ElicitRequestParams |
| 208 | + { |
| 209 | + Message = $"Hi {name}! How old are you?", |
| 210 | + RequestedSchema = new() |
| 211 | + { |
| 212 | + Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition> |
| 213 | + { |
| 214 | + ["age"] = new ElicitRequestParams.NumberSchema |
| 215 | + { |
| 216 | + Description = "Your age" |
| 217 | + } |
| 218 | + } |
| 219 | + } |
| 220 | + }) |
| 221 | + }, |
| 222 | + requestState: "step-2"); |
| 223 | + } |
| 224 | + |
| 225 | + if (!server.IsMrtrSupported) |
| 226 | + { |
| 227 | + return "MRTR is not supported. Please use a compatible client."; |
| 228 | + } |
| 229 | + |
| 230 | + // First round — ask for name |
| 231 | + throw new InputRequiredException( |
| 232 | + inputRequests: new Dictionary<string, InputRequest> |
| 233 | + { |
| 234 | + ["name"] = InputRequest.ForElicitation(new ElicitRequestParams |
| 235 | + { |
| 236 | + Message = "What's your name?", |
| 237 | + RequestedSchema = new() |
| 238 | + { |
| 239 | + Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition> |
| 240 | + { |
| 241 | + ["name"] = new ElicitRequestParams.StringSchema |
| 242 | + { |
| 243 | + Description = "Your name" |
| 244 | + } |
| 245 | + } |
| 246 | + } |
| 247 | + }) |
| 248 | + }, |
| 249 | + requestState: "step-1"); |
| 250 | +} |
| 251 | +``` |
| 252 | + |
| 253 | +### Providing custom error messages |
| 254 | + |
| 255 | +When MRTR is not supported, you can provide domain-specific guidance: |
| 256 | + |
| 257 | +```csharp |
| 258 | +if (!server.IsMrtrSupported) |
| 259 | +{ |
| 260 | + return "This tool requires interactive input. To use it:\n" |
| 261 | + + "1. Connect with a client that negotiates MCP protocol revision DRAFT-2026-v1, or\n" |
| 262 | + + "2. Use a stateful current-protocol session so the server can resolve the input requests for you.\n" |
| 263 | + + "\nStateless current-protocol sessions cannot resolve MRTR input requests."; |
| 264 | +} |
| 265 | +``` |
| 266 | + |
| 267 | +## Compatibility |
| 268 | + |
| 269 | +The SDK supports `InputRequiredException` across two protocol revisions and two session modes: |
| 270 | + |
| 271 | +| Negotiated protocol | Session mode | Behavior | |
| 272 | +|---|---|---| |
| 273 | +| `DRAFT-2026-v1` | Stateful | Native MRTR — `InputRequiredResult` is serialized directly to the wire. | |
| 274 | +| `DRAFT-2026-v1` | Stateless | Native MRTR — `InputRequiredResult` is serialized directly to the wire. No server-side handler state needed. | |
| 275 | +| 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. | |
| 276 | +| 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. | |
| 277 | + |
| 278 | +> [!NOTE] |
| 279 | +> The backcompat resolver is intentionally limited to 10 retry rounds. Tools that need more rounds should require `DRAFT-2026-v1` (check `IsMrtrSupported`). |
| 280 | +
|
| 281 | +### Why `ElicitAsync` / `SampleAsync` / `RequestRootsAsync` throw on stateless servers |
| 282 | + |
| 283 | +`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). |
| 284 | + |
| 285 | +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. |
| 286 | + |
| 287 | +### Future direction |
| 288 | + |
| 289 | +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. |
| 290 | + |
| 291 | +This work is a follow-up to the present PR. |
0 commit comments