|
| 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 an **experimental feature** based on a draft MCP specification proposal. The API may change in future releases. See the [Experimental APIs](../../experimental.md) documentation for details on working with experimental APIs. Both the client and server must opt in via <xref:ModelContextProtocol.Client.McpClientOptions.ExperimentalProtocolVersion> and <xref:ModelContextProtocol.Server.McpServerOptions.ExperimentalProtocolVersion> respectively. |
| 13 | +
|
| 14 | +Multi Round-Trip Requests (MRTR) allow a server tool to 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 JSON-RPC request for each interaction. Instead of sending 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 |
| 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 `IncompleteResult` containing `inputRequests` and/or `requestState`. |
| 30 | +3. The client resolves each input request (e.g., prompts the user for elicitation, calls an LLM for sampling). |
| 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 `IncompleteResult` for additional rounds. |
| 33 | + |
| 34 | +## Opting in |
| 35 | + |
| 36 | +MRTR requires both the client and server to opt in by setting `ExperimentalProtocolVersion` to a draft protocol version. Currently, this is `"2026-06-XX"`: |
| 37 | + |
| 38 | +```csharp |
| 39 | +// Server |
| 40 | +var builder = Host.CreateApplicationBuilder(); |
| 41 | +builder.Services.AddMcpServer(options => |
| 42 | +{ |
| 43 | + options.ExperimentalProtocolVersion = "2026-06-XX"; |
| 44 | +}) |
| 45 | +.WithTools<MyTools>(); |
| 46 | +``` |
| 47 | + |
| 48 | +```csharp |
| 49 | +// Client |
| 50 | +var options = new McpClientOptions |
| 51 | +{ |
| 52 | + ExperimentalProtocolVersion = "2026-06-XX", |
| 53 | + Handlers = new McpClientHandlers |
| 54 | + { |
| 55 | + ElicitationHandler = HandleElicitationAsync, |
| 56 | + SamplingHandler = HandleSamplingAsync, |
| 57 | + } |
| 58 | +}; |
| 59 | +``` |
| 60 | + |
| 61 | +When both sides opt in, the negotiated protocol version activates MRTR. When either side does not opt in, the SDK gracefully falls back to standard behavior. |
| 62 | + |
| 63 | +## High-level API |
| 64 | + |
| 65 | +The high-level API lets tool handlers call <xref:ModelContextProtocol.Server.McpServer.ElicitAsync*> and <xref:ModelContextProtocol.Server.McpServer.SampleAsync*> as if they were simple async calls. The SDK transparently manages the incomplete result / retry cycle. |
| 66 | + |
| 67 | +```csharp |
| 68 | +[McpServerToolType] |
| 69 | +public class InteractiveTools |
| 70 | +{ |
| 71 | + [McpServerTool, Description("Asks the user for confirmation before proceeding")] |
| 72 | + public static async Task<string> ConfirmAction( |
| 73 | + McpServer server, |
| 74 | + [Description("The action to confirm")] string action, |
| 75 | + CancellationToken cancellationToken) |
| 76 | + { |
| 77 | + var result = await server.ElicitAsync(new ElicitRequestParams |
| 78 | + { |
| 79 | + Message = $"Do you want to proceed with: {action}?", |
| 80 | + RequestedSchema = new() |
| 81 | + { |
| 82 | + Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition> |
| 83 | + { |
| 84 | + ["confirm"] = new ElicitRequestParams.BooleanSchema |
| 85 | + { |
| 86 | + Description = "Confirm the action" |
| 87 | + } |
| 88 | + } |
| 89 | + } |
| 90 | + }, cancellationToken); |
| 91 | + |
| 92 | + return result.Action == "accept" ? "Action confirmed!" : "Action cancelled."; |
| 93 | + } |
| 94 | +} |
| 95 | +``` |
| 96 | + |
| 97 | +From the client's perspective, this is a single `CallToolAsync` call. The SDK handles all retries automatically: |
| 98 | + |
| 99 | +```csharp |
| 100 | +var result = await client.CallToolAsync("ConfirmAction", new { action = "delete all files" }); |
| 101 | +Console.WriteLine(result.Content.OfType<TextContentBlock>().First().Text); |
| 102 | +``` |
| 103 | + |
| 104 | +> [!TIP] |
| 105 | +> The high-level API requires session affinity — the handler task stays suspended in server memory between round trips. This works well for stateful (non-stateless) server configurations. |
| 106 | +
|
| 107 | +## Low-level API |
| 108 | + |
| 109 | +The low-level API gives tool handlers direct control over `inputRequests` and `requestState`. This enables stateless multi-round-trip flows where the server does not need to keep handler state in memory between retries. |
| 110 | + |
| 111 | +### Checking MRTR support |
| 112 | + |
| 113 | +Before using the low-level API, check <xref:ModelContextProtocol.Server.McpServer.IsMrtrSupported> to determine if the connected client supports MRTR. If it does not, provide a fallback experience: |
| 114 | + |
| 115 | +```csharp |
| 116 | +[McpServerTool, Description("A tool that uses low-level MRTR")] |
| 117 | +public static string MyTool( |
| 118 | + McpServer server, |
| 119 | + RequestContext<CallToolRequestParams> context) |
| 120 | +{ |
| 121 | + if (!server.IsMrtrSupported) |
| 122 | + { |
| 123 | + return "This tool requires a client that supports multi-round-trip requests. " |
| 124 | + + "Please upgrade your client or enable experimental protocol support."; |
| 125 | + } |
| 126 | + |
| 127 | + // ... MRTR logic |
| 128 | +} |
| 129 | +``` |
| 130 | + |
| 131 | +### Returning an incomplete result |
| 132 | + |
| 133 | +Throw <xref:ModelContextProtocol.Protocol.IncompleteResultException> to return an incomplete result to the client. The exception carries an <xref:ModelContextProtocol.Protocol.IncompleteResult> containing `inputRequests` and/or `requestState`: |
| 134 | + |
| 135 | +```csharp |
| 136 | +[McpServerTool, Description("Stateless tool managing its own MRTR flow")] |
| 137 | +public static string StatelessTool( |
| 138 | + McpServer server, |
| 139 | + RequestContext<CallToolRequestParams> context, |
| 140 | + [Description("The user's question")] string question) |
| 141 | +{ |
| 142 | + var requestState = context.Params!.RequestState; |
| 143 | + var inputResponses = context.Params!.InputResponses; |
| 144 | + |
| 145 | + // On retry, process the client's responses |
| 146 | + if (requestState is not null && inputResponses is not null) |
| 147 | + { |
| 148 | + var elicitResult = inputResponses["user_answer"].ElicitationResult; |
| 149 | + return $"You answered: {elicitResult?.Content?.FirstOrDefault().Value}"; |
| 150 | + } |
| 151 | + |
| 152 | + if (!server.IsMrtrSupported) |
| 153 | + { |
| 154 | + return "MRTR is not supported by this client."; |
| 155 | + } |
| 156 | + |
| 157 | + // First call — request user input |
| 158 | + throw new IncompleteResultException( |
| 159 | + inputRequests: new Dictionary<string, InputRequest> |
| 160 | + { |
| 161 | + ["user_answer"] = InputRequest.ForElicitation(new ElicitRequestParams |
| 162 | + { |
| 163 | + Message = $"Please answer: {question}", |
| 164 | + RequestedSchema = new() |
| 165 | + { |
| 166 | + Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition> |
| 167 | + { |
| 168 | + ["answer"] = new ElicitRequestParams.StringSchema |
| 169 | + { |
| 170 | + Description = "Your answer" |
| 171 | + } |
| 172 | + } |
| 173 | + } |
| 174 | + }) |
| 175 | + }, |
| 176 | + requestState: "awaiting-answer"); |
| 177 | +} |
| 178 | +``` |
| 179 | + |
| 180 | +### Accessing retry data |
| 181 | + |
| 182 | +When the client retries a tool call, the retry data is available on the request parameters: |
| 183 | + |
| 184 | +- <xref:ModelContextProtocol.Protocol.RequestParams.InputResponses> — a dictionary of client responses keyed by the same keys used in `inputRequests` |
| 185 | +- <xref:ModelContextProtocol.Protocol.RequestParams.RequestState> — the opaque state string echoed back by the client |
| 186 | + |
| 187 | +Each `InputResponse` has typed accessors for the response type: |
| 188 | + |
| 189 | +- `ElicitationResult` — the result of an elicitation request |
| 190 | +- `SamplingResult` — the result of a sampling request |
| 191 | +- `RootsResult` — the result of a roots list request |
| 192 | + |
| 193 | +### Load shedding with requestState-only responses |
| 194 | + |
| 195 | +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: |
| 196 | + |
| 197 | +```csharp |
| 198 | +[McpServerTool, Description("Tool that defers work using requestState")] |
| 199 | +public static string DeferredTool( |
| 200 | + McpServer server, |
| 201 | + RequestContext<CallToolRequestParams> context) |
| 202 | +{ |
| 203 | + var requestState = context.Params!.RequestState; |
| 204 | + |
| 205 | + if (requestState is not null) |
| 206 | + { |
| 207 | + // Resume deferred work |
| 208 | + var state = JsonSerializer.Deserialize<MyState>( |
| 209 | + Convert.FromBase64String(requestState)); |
| 210 | + return $"Completed step {state!.Step}"; |
| 211 | + } |
| 212 | + |
| 213 | + if (!server.IsMrtrSupported) |
| 214 | + { |
| 215 | + return "MRTR is not supported by this client."; |
| 216 | + } |
| 217 | + |
| 218 | + // Defer work to a later retry |
| 219 | + var initialState = new MyState { Step = 1 }; |
| 220 | + throw new IncompleteResultException( |
| 221 | + requestState: Convert.ToBase64String( |
| 222 | + JsonSerializer.SerializeToUtf8Bytes(initialState))); |
| 223 | +} |
| 224 | +``` |
| 225 | + |
| 226 | +The client automatically retries `requestState`-only incomplete results, echoing the state back without needing to resolve any input requests. |
| 227 | + |
| 228 | +### Multiple round trips |
| 229 | + |
| 230 | +A tool can perform multiple rounds of interaction by throwing `IncompleteResultException` multiple times across retries: |
| 231 | + |
| 232 | +```csharp |
| 233 | +[McpServerTool, Description("Multi-step wizard")] |
| 234 | +public static string WizardTool( |
| 235 | + McpServer server, |
| 236 | + RequestContext<CallToolRequestParams> context) |
| 237 | +{ |
| 238 | + var requestState = context.Params!.RequestState; |
| 239 | + var inputResponses = context.Params!.InputResponses; |
| 240 | + |
| 241 | + if (requestState == "step-2" && inputResponses is not null) |
| 242 | + { |
| 243 | + var name = inputResponses["name"].ElicitationResult?.Content?.FirstOrDefault().Value; |
| 244 | + var age = inputResponses["age"].ElicitationResult?.Content?.FirstOrDefault().Value; |
| 245 | + return $"Welcome, {name}! You are {age} years old."; |
| 246 | + } |
| 247 | + |
| 248 | + if (requestState == "step-1" && inputResponses is not null) |
| 249 | + { |
| 250 | + var name = inputResponses["name"].ElicitationResult?.Content?.FirstOrDefault().Value; |
| 251 | + |
| 252 | + // Second round — ask for age |
| 253 | + throw new IncompleteResultException( |
| 254 | + inputRequests: new Dictionary<string, InputRequest> |
| 255 | + { |
| 256 | + ["age"] = InputRequest.ForElicitation(new ElicitRequestParams |
| 257 | + { |
| 258 | + Message = $"Hi {name}! How old are you?", |
| 259 | + RequestedSchema = new() |
| 260 | + { |
| 261 | + Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition> |
| 262 | + { |
| 263 | + ["age"] = new ElicitRequestParams.NumberSchema |
| 264 | + { |
| 265 | + Description = "Your age" |
| 266 | + } |
| 267 | + } |
| 268 | + } |
| 269 | + }) |
| 270 | + }, |
| 271 | + requestState: "step-2"); |
| 272 | + } |
| 273 | + |
| 274 | + if (!server.IsMrtrSupported) |
| 275 | + { |
| 276 | + return "MRTR is not supported. Please use a compatible client."; |
| 277 | + } |
| 278 | + |
| 279 | + // First round — ask for name |
| 280 | + throw new IncompleteResultException( |
| 281 | + inputRequests: new Dictionary<string, InputRequest> |
| 282 | + { |
| 283 | + ["name"] = InputRequest.ForElicitation(new ElicitRequestParams |
| 284 | + { |
| 285 | + Message = "What's your name?", |
| 286 | + RequestedSchema = new() |
| 287 | + { |
| 288 | + Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition> |
| 289 | + { |
| 290 | + ["name"] = new ElicitRequestParams.StringSchema |
| 291 | + { |
| 292 | + Description = "Your name" |
| 293 | + } |
| 294 | + } |
| 295 | + } |
| 296 | + }) |
| 297 | + }, |
| 298 | + requestState: "step-1"); |
| 299 | +} |
| 300 | +``` |
| 301 | + |
| 302 | +### Providing custom error messages |
| 303 | + |
| 304 | +When MRTR is not supported, you can provide domain-specific guidance: |
| 305 | + |
| 306 | +```csharp |
| 307 | +if (!server.IsMrtrSupported) |
| 308 | +{ |
| 309 | + return "This tool requires interactive input, but your client doesn't support " |
| 310 | + + "multi-round-trip requests. To use this feature:\n" |
| 311 | + + "1. Update to a client that supports MCP protocol version 2026-06-XX or later\n" |
| 312 | + + "2. Enable the experimental protocol version in your client configuration\n" |
| 313 | + + "\nFor more information, see: https://example.com/mrtr-setup"; |
| 314 | +} |
| 315 | +``` |
| 316 | + |
| 317 | +## Compatibility |
| 318 | + |
| 319 | +The SDK handles all four combinations of experimental/non-experimental client and server: |
| 320 | + |
| 321 | +| Server Experimental | Client Experimental | Behavior | |
| 322 | +|---|---|---| |
| 323 | +| ✅ | ✅ | MRTR — incomplete results with retry cycle | |
| 324 | +| ✅ | ❌ | Server falls back to legacy JSON-RPC requests for elicitation/sampling | |
| 325 | +| ❌ | ✅ | Client accepts stable protocol version; MRTR retry loop is a no-op | |
| 326 | +| ❌ | ❌ | Standard behavior — no MRTR | |
| 327 | + |
| 328 | +When a server has MRTR enabled but the connected client does not: |
| 329 | + |
| 330 | +- The high-level API (`ElicitAsync`, `SampleAsync`) automatically falls back to sending standard JSON-RPC requests — no code changes needed. |
| 331 | +- The low-level API reports `IsMrtrSupported == false`, allowing the tool to provide a custom fallback message. |
| 332 | +- Throwing `IncompleteResultException` when MRTR is not supported results in a JSON-RPC error being returned to the client. |
| 333 | + |
| 334 | +## Choosing between high-level and low-level APIs |
| 335 | + |
| 336 | +| Consideration | High-level API | Low-level API | |
| 337 | +|---|---|---| |
| 338 | +| **Session affinity** | Required — handler stays suspended in memory | Not required — handler completes each round | |
| 339 | +| **State management** | Automatic (SDK manages via `MrtrContext`) | Manual (`requestState` encoded by you) | |
| 340 | +| **Complexity** | Simple `await` calls | More code, but full control | |
| 341 | +| **Stateless servers** | Not compatible | Designed for stateless scenarios | |
| 342 | +| **Fallback** | Automatic — SDK sends legacy requests | Manual — check `IsMrtrSupported` | |
| 343 | +| **Multiple input types** | One at a time (elicit or sample) | Multiple in a single round | |
0 commit comments