Skip to content

Commit 3cafe8d

Browse files
halter73Copilot
andcommitted
Add low-level MRTR server API and documentation
- Add IncompleteResultException for tool handlers to return incomplete results with inputRequests and/or requestState directly - Add McpServer.IsMrtrSupported property for checking client compatibility - Handle IncompleteResultException in MRTR wrapper and race handler - Validate MRTR support when exception is thrown (returns JSON-RPC error if client doesn't support MRTR) - Fall through to MRTR-aware invocation for unmatched requestState retries - Add 8 protocol conformance tests (raw HTTP) for low-level MRTR flows - Add 7 integration tests for client auto-retry of low-level tools - Add MRTR concept documentation covering both high-level and low-level APIs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e81a4ec commit 3cafe8d

8 files changed

Lines changed: 1190 additions & 32 deletions

File tree

docs/concepts/mrtr/mrtr.md

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
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 |

docs/concepts/toc.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ items:
1919
uid: pagination
2020
- name: Tasks
2121
uid: tasks
22+
- name: Multi Round-Trip Requests (MRTR)
23+
uid: mrtr
2224
- name: Client Features
2325
items:
2426
- name: Roots

0 commit comments

Comments
 (0)