Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 782b5bad-4046-49ec-93a6-72768b5b7521 Copilot-Session: 4b938335-418c-4ee7-8287-6c70d9148b89
394 lines
21 KiB
Markdown
394 lines
21 KiB
Markdown
---
|
|
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)
|
|
|
|
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 **`2026-07-28`**. The C# SDK client prefers `2026-07-28` by default — it probes with `server/discover` and falls back to an `initialize` handshake only when the server doesn't support it. Stateless HTTP servers accept `2026-07-28` automatically when a client offers it; HTTP servers configured with `Stateless = false` refuse that revision with `UnsupportedProtocolVersion` so dual-path clients can fall back to a session-capable revision. No experimental flags are required; pinning `ProtocolVersion` to an initialize-capable revision opts back out.
|
|
|
|
```csharp
|
|
// Client — the SDK prefers 2026-07-28 (and therefore MRTR) by default.
|
|
var clientOptions = new McpClientOptions
|
|
{
|
|
Handlers = new McpClientHandlers
|
|
{
|
|
ElicitationHandler = HandleElicitationAsync,
|
|
SamplingHandler = HandleSamplingAsync,
|
|
}
|
|
};
|
|
```
|
|
|
|
Under `2026-07-28`, 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 `2026-07-28` Streamable HTTP server (where Streamable HTTP no longer supports sessions) 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 — including `2026-07-28` stdio sessions — but they throw `InvalidOperationException("X is not supported in stateless mode.")` on every stateless session.
|
|
|
|
Under `2025-11-25` and earlier, `InputRequiredException` is still supported in stateful sessions via a backward-compatibility resolver — see the [Compatibility](#compatibility) section.
|
|
|
|
## 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`. The property returns `true` when either:
|
|
|
|
- The negotiated protocol revision is `2026-07-28` (MRTR is native), or
|
|
- The session is stateful under protocol revision `2025-11-25` (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 2026-07-28, "
|
|
+ "or a stateful session using protocol revision 2025-11-25.";
|
|
}
|
|
|
|
// ... 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.
|
|
|
|
| Input | Deserialize call |
|
|
|-------------|-----------------------------------------------------------------------|
|
|
| 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");
|
|
}
|
|
```
|
|
|
|
### Supporting down-level clients
|
|
|
|
When <xref:ModelContextProtocol.Server.McpServer.IsMrtrSupported> is `false`, the tool
|
|
can't obtain client input through an input request — but that doesn't have to be a dead
|
|
end. Handle down-level and stateless callers along a spectrum, from a helpful message to a
|
|
fully functional non-interactive path.
|
|
|
|
#### Return actionable guidance
|
|
|
|
At minimum, tell the caller how to proceed instead of failing opaquely. Explain what kind
|
|
of session would enable the interactive path, and — when the tool exposes one — how to
|
|
invoke the non-interactive path described below:
|
|
|
|
```csharp
|
|
if (!server.IsMrtrSupported)
|
|
{
|
|
return "This tool needs interactive input. Connect with a client that negotiates MCP "
|
|
+ "protocol revision 2026-07-28, or use a stateful session using revision 2025-11-25 "
|
|
+ "so the server can resolve the input requests for you.";
|
|
}
|
|
```
|
|
|
|
#### Offer a non-interactive fallback
|
|
|
|
When a tool *can* complete without a prompt, expose an explicit argument that lets a
|
|
down-level or stateless caller opt in directly. The tool stays usable everywhere: it
|
|
elicits input when MRTR is available, and otherwise returns guidance the caller
|
|
(or its model) can act on by resending with the argument set.
|
|
|
|
The tool below is just an example that has a sensible non-interactive fallback. It also
|
|
shows how to branch on the elicitation **action** rather than assuming acceptance: the
|
|
deserialized <xref:ModelContextProtocol.Protocol.ElicitResult> carries an
|
|
<xref:ModelContextProtocol.Protocol.ElicitResult.Action> of `"accept"`, `"decline"`, or
|
|
`"cancel"`, surfaced through the
|
|
<xref:ModelContextProtocol.Protocol.ElicitResult.IsAccepted> shorthand.
|
|
|
|
```csharp
|
|
[McpServerTool, Description("Closes a support ticket, recording why it was closed.")]
|
|
public static string CloseSupportTicket(
|
|
McpServer server,
|
|
RequestContext<CallToolRequestParams> context,
|
|
[Description("The ID of the ticket to close")] long ticketId,
|
|
[Description("Why the ticket is being closed")] string? closeReason = null)
|
|
{
|
|
// Handles four client scenarios:
|
|
// 1. Provided up-front: client sends `closeReason` in the initial call
|
|
// 2. MRTR round-trip request: client confirms via `InputResponses["closeReason"]`
|
|
// 3. MRTR initial request: server proposes a default reason and asks for confirmation
|
|
// (with automatic SDK down-level bridge)
|
|
// 4. Session-less down-level: server returns a guidance message requesting the reason up-front
|
|
|
|
// The default reason proposed to the caller and used if none is provided.
|
|
string defaultCloseReason = "completed";
|
|
|
|
// (1) Provided up-front. Works on any client, including down-level session-less.
|
|
// These requests are typically sent after (4) returns a guidance message.
|
|
var confirmedReason = closeReason;
|
|
|
|
// (2) MRTR round-trip request. Works with native MRTR support or the automatic down-level
|
|
// SDK bridge after (3) throws an `InputRequiredException` to request a `closeReason`.
|
|
if (string.IsNullOrWhiteSpace(confirmedReason) &&
|
|
context.Params?.InputResponses?.TryGetValue("closeReason", out var reasonResponse) is true)
|
|
{
|
|
var reasonResult = reasonResponse.Deserialize(InputResponse.ElicitResultJsonTypeInfo);
|
|
|
|
// Branch on the elicitation action: `decline` or `cancel` leaves the ticket open.
|
|
if (reasonResult?.IsAccepted is not true) return "Ticket close cancelled";
|
|
|
|
// Accepted: use the reason the caller confirmed, falling back to the proposed default.
|
|
confirmedReason = reasonResult.Content?.TryGetValue("closeReason", out var reasonValue) is true
|
|
? reasonValue.GetString()
|
|
: null;
|
|
confirmedReason = string.IsNullOrWhiteSpace(confirmedReason) ? defaultCloseReason : confirmedReason;
|
|
}
|
|
|
|
// (1) or (2) A reason is in hand; proceed with closing the ticket.
|
|
if (!string.IsNullOrWhiteSpace(confirmedReason))
|
|
return $"Closed ticket {ticketId}: {confirmedReason}";
|
|
|
|
// (3) MRTR initial request: propose "completed" as the default reason and ask the caller
|
|
// to confirm (or adjust) it. This uses the 2026-07-28 MRTR input request, but the SDK
|
|
// provides an automatic bridge to a legacy elicitation on a down-level, stateful
|
|
// session. When the bridge can be provided, `server.IsMrtrSupported` is `true` and the
|
|
// exception leads to a legacy elicitation response automatically.
|
|
if (server.IsMrtrSupported)
|
|
{
|
|
throw new InputRequiredException(
|
|
inputRequests: new Dictionary<string, InputRequest>
|
|
{
|
|
["closeReason"] = InputRequest.ForElicitation(new ElicitRequestParams
|
|
{
|
|
Message = $"Close ticket '{ticketId}'? Accept the default reason or provide your own.",
|
|
RequestedSchema = new()
|
|
{
|
|
Properties =
|
|
{
|
|
["closeReason"] = new ElicitRequestParams.StringSchema
|
|
{
|
|
Title = "Close reason",
|
|
Description = "The reason for closing the ticket",
|
|
Default = defaultCloseReason,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
},
|
|
requestState: ticketId.ToString()); // opaque; echoed back to us on the retry
|
|
}
|
|
|
|
// (4) Down-level and stateless: we can't prompt an elicitation through an MRTR
|
|
// round-trip request or an elicitation. Return a natural language response
|
|
// with guidance for providing the reason up-front.
|
|
return "Closing a ticket requires a reason. Resend with `closeReason`.";
|
|
}
|
|
```
|
|
|
|
> [!IMPORTANT]
|
|
> Form-mode elicitations must set `RequestedSchema`. This tool proposes a default, so its
|
|
> schema declares an optional `closeReason` string with a `Default` the caller can accept or
|
|
> override. A confirmation prompt that collects no fields still needs a schema — set
|
|
> `RequestedSchema = new()` (an empty object schema): it's accepted by native `2026-07-28` MRTR
|
|
> and **required** by the down-level stateful elicitation bridge under `2025-11-25`, which
|
|
> otherwise throws `ArgumentException: "Form mode elicitation requests require a requested schema."`.
|
|
|
|
## Compatibility
|
|
|
|
The SDK supports `InputRequiredException` across two protocol revisions and two session modes:
|
|
|
|
| Negotiated protocol | Session mode | Behavior |
|
|
|----------------------------------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
| `2026-07-28` | Stateful | Native MRTR — `InputRequiredResult` is serialized directly to the wire. |
|
|
| `2026-07-28` | Stateless | Native MRTR — `InputRequiredResult` is serialized directly to the wire. No server-side handler state needed. |
|
|
| `2025-11-25` 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. |
|
|
| `2025-11-25` 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 `2026-07-28` (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 `2025-11-25` 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 `2026-07-28`, the spec removes those request methods from Streamable HTTP entirely; the SDK still allows the legacy methods on `2026-07-28` 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.
|
|
|
|
Because `2026-07-28` removes `Mcp-Session-Id` (SEP-2567) and the `initialize` handshake (SEP-2575), Streamable HTTP can serve that revision only through the stateless path. The `Stateful` row for `2026-07-28` in the compatibility matrix above therefore applies to stdio and other non-HTTP stateful sessions; an HTTP server explicitly set to `Stateless = false` refuses `2026-07-28` with `UnsupportedProtocolVersion` and creates a session only when an older client falls back to `initialize`.
|