Skip to content

Commit 5db1781

Browse files
halter73Copilot
andcommitted
Gate high-level server-to-client requests on stateless mode, not draft protocol
ElicitAsync/SampleAsync/RequestRootsAsync now throw only when the server is stateless (the existing ThrowIf*Unsupported guards already handled this). Stdio + DRAFT-2026-v1 keeps working via the legacy server-to-client JSON-RPC path; stateless Streamable HTTP throws regardless of protocol revision. A follow-up will force DRAFT-2026-v1 Streamable HTTP to stateless mode. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 75fe8ee commit 5db1781

13 files changed

Lines changed: 199 additions & 215 deletions

File tree

docs/concepts/elicitation/elicitation.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,10 +172,10 @@ Here's an example implementation of how a console application might handle elici
172172

173173
### Multi Round-Trip Requests (MRTR)
174174

175-
[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `DRAFT-2026-v1`. Under the draft protocol, the server-to-client `elicitation/create` request method is removed; the only supported way to ask the user for input from a server handler is to throw <xref:ModelContextProtocol.Protocol.InputRequiredException> and let the SDK emit an <xref:ModelContextProtocol.Protocol.InputRequiredResult> on the wire.
175+
[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `DRAFT-2026-v1`. Under the draft protocol, the server-to-client `elicitation/create` request method is removed; the recommended way to ask the user for input from a server handler is to throw <xref:ModelContextProtocol.Protocol.InputRequiredException> and let the SDK emit an <xref:ModelContextProtocol.Protocol.InputRequiredResult> on the wire.
176176

177177
> [!IMPORTANT]
178-
> Calling `ElicitAsync` after negotiating `DRAFT-2026-v1` throws `InvalidOperationException`. Use `InputRequiredException` from your tool/prompt handler instead — it works under both the current protocol (resolved via legacy JSON-RPC + retry on stateful sessions) and the draft protocol (wire-format `InputRequiredResult`, no server-side handler state required).
178+
> `ElicitAsync` throws `InvalidOperationException("Elicitation is not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `DRAFT-2026-v1` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `elicitation/create` request flow. For code that needs to run on stateless servers — including all `DRAFT-2026-v1` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes.
179179
180180
For example:
181181

@@ -188,7 +188,7 @@ public static string ElicitWithMrtr(
188188
// On retry, process the client's elicitation response
189189
if (context.Params!.InputResponses?.TryGetValue("user_input", out var response) is true)
190190
{
191-
var elicitResult = response.Deserialize(InputResponse.ElicitResultTypeInfo);
191+
var elicitResult = response.Deserialize(InputResponse.ElicitResultJsonTypeInfo);
192192
return elicitResult?.Action == "accept"
193193
? $"User accepted: {elicitResult.Content?.FirstOrDefault().Value}"
194194
: "User declined.";

docs/concepts/mrtr/mrtr.md

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ var clientOptions = new McpClientOptions
4848
};
4949
```
5050

51-
Under `DRAFT-2026-v1`, MRTR is the **only** way to obtain client input from a server handler. The legacy server-to-client `elicitation/create`, `sampling/createMessage`, and `roots/list` request methods are removed; calling <xref:ModelContextProtocol.Server.McpServer.ElicitAsync*>, <xref:ModelContextProtocol.Server.McpServer.SampleAsync*>, or <xref:ModelContextProtocol.Server.McpServer.RequestRootsAsync*> on a server that negotiated `DRAFT-2026-v1` throws `InvalidOperationException`. Tools that need client input must throw <xref:ModelContextProtocol.Protocol.InputRequiredException> instead.
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.
5252

5353
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.
5454

@@ -96,7 +96,7 @@ public static string AnswerTool(
9696
// On retry, process the client's responses
9797
if (requestState is not null && inputResponses is not null)
9898
{
99-
var elicitResult = inputResponses["user_answer"].Deserialize(InputResponse.ElicitResultTypeInfo);
99+
var elicitResult = inputResponses["user_answer"].Deserialize(InputResponse.ElicitResultJsonTypeInfo);
100100
return $"You answered: {elicitResult?.Content?.FirstOrDefault().Value}";
101101
}
102102

@@ -137,9 +137,9 @@ When the client retries a tool call, the retry data is available on the request
137137

138138
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.
139139

140-
- Elicitation — `response.Deserialize(InputResponse.ElicitResultTypeInfo)`
141-
- Sampling — `response.Deserialize(InputResponse.SamplingResultTypeInfo)`
142-
- Roots list — `response.Deserialize(InputResponse.RootsResultTypeInfo)`
140+
- Elicitation — `response.Deserialize(InputResponse.ElicitResultJsonTypeInfo)`
141+
- Sampling — `response.Deserialize(InputResponse.CreateMessageResultJsonTypeInfo)`
142+
- Roots list — `response.Deserialize(InputResponse.ListRootsResultJsonTypeInfo)`
143143

144144
### Load shedding with requestState-only responses
145145

@@ -191,14 +191,14 @@ public static string WizardTool(
191191

192192
if (requestState == "step-2" && inputResponses is not null)
193193
{
194-
var name = inputResponses["name"].Deserialize(InputResponse.ElicitResultTypeInfo)?.Content?.FirstOrDefault().Value;
195-
var age = inputResponses["age"].Deserialize(InputResponse.ElicitResultTypeInfo)?.Content?.FirstOrDefault().Value;
194+
var name = inputResponses["name"].Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Content?.FirstOrDefault().Value;
195+
var age = inputResponses["age"].Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Content?.FirstOrDefault().Value;
196196
return $"Welcome, {name}! You are {age} years old.";
197197
}
198198

199199
if (requestState == "step-1" && inputResponses is not null)
200200
{
201-
var name = inputResponses["name"].Deserialize(InputResponse.ElicitResultTypeInfo)?.Content?.FirstOrDefault().Value;
201+
var name = inputResponses["name"].Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Content?.FirstOrDefault().Value;
202202

203203
// Second round — ask for age
204204
throw new InputRequiredException(
@@ -278,14 +278,14 @@ The SDK supports `InputRequiredException` across two protocol revisions and two
278278
> [!NOTE]
279279
> The backcompat resolver is intentionally limited to 10 retry rounds. Tools that need more rounds should require `DRAFT-2026-v1` (check `IsMrtrSupported`).
280280
281-
### Why `ElicitAsync` / `SampleAsync` / `RequestRootsAsync` throw under draft
281+
### Why `ElicitAsync` / `SampleAsync` / `RequestRootsAsync` throw on stateless servers
282282

283-
The `DRAFT-2026-v1` revision removes the server-to-client `elicitation/create`, `sampling/createMessage`, and `roots/list` request methods entirely. Servers cannot use those request methods because clients no longer advertise the corresponding capabilities or implement handlers for them. The SDK fails fast with a clear `InvalidOperationException` so you can fix the call site before it manifests as a wire-level error.
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).
284284

285-
Under the current protocol revision (`2025-06-18` and earlier), these methods continue to work normally and are the recommended way to do simple, one-shot client interactions. `InputRequiredException` is the way to write tools that work the same on both revisions.
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.
286286

287287
### Future direction
288288

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 happens, the `Stateful` row of the compatibility matrix above collapses into the `Stateless` row, and `InputRequiredException` becomes uniformly native across both. The current-protocol resolver path will remain for backward compatibility with older clients and stateful servers.
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.
290290

291291
This work is a follow-up to the present PR.

docs/concepts/roots/roots.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,10 @@ server.RegisterNotificationHandler(
106106

107107
### Multi Round-Trip Requests (MRTR)
108108

109-
[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `DRAFT-2026-v1`. Under the draft protocol, the server-to-client `roots/list` request method is removed; the only supported way to ask the client for its roots from a server handler is to throw <xref:ModelContextProtocol.Protocol.InputRequiredException> and let the SDK emit an <xref:ModelContextProtocol.Protocol.InputRequiredResult> on the wire.
109+
[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `DRAFT-2026-v1`. Under the draft protocol, the server-to-client `roots/list` request method is removed; the recommended way to ask the client for its roots from a server handler is to throw <xref:ModelContextProtocol.Protocol.InputRequiredException> and let the SDK emit an <xref:ModelContextProtocol.Protocol.InputRequiredResult> on the wire.
110110

111111
> [!IMPORTANT]
112-
> Calling `RequestRootsAsync` after negotiating `DRAFT-2026-v1` throws `InvalidOperationException`. Use `InputRequiredException` from your tool/prompt handler instead — it works under both the current protocol (resolved via legacy JSON-RPC + retry on stateful sessions) and the draft protocol (wire-format `InputRequiredResult`, no server-side handler state required).
112+
> `RequestRootsAsync` throws `InvalidOperationException("Roots are not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `DRAFT-2026-v1` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `roots/list` request flow. For code that needs to run on stateless servers — including all `DRAFT-2026-v1` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes.
113113
114114
For example:
115115

@@ -122,7 +122,7 @@ public static string ListRootsWithMrtr(
122122
// On retry, process the client's roots response
123123
if (context.Params!.InputResponses?.TryGetValue("get_roots", out var response) is true)
124124
{
125-
var roots = response.Deserialize(InputResponse.RootsResultTypeInfo)?.Roots ?? [];
125+
var roots = response.Deserialize(InputResponse.ListRootsResultJsonTypeInfo)?.Roots ?? [];
126126
return $"Found {roots.Count} roots: {string.Join(", ", roots.Select(r => r.Uri))}";
127127
}
128128

docs/concepts/sampling/sampling.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,10 @@ Sampling requires the client to advertise the `sampling` capability. This is han
123123

124124
### Multi Round-Trip Requests (MRTR)
125125

126-
[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `DRAFT-2026-v1`. Under the draft protocol, the server-to-client `sampling/createMessage` request method is removed; the only supported way to ask the client to sample from a server handler is to throw <xref:ModelContextProtocol.Protocol.InputRequiredException> and let the SDK emit an <xref:ModelContextProtocol.Protocol.InputRequiredResult> on the wire.
126+
[MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `DRAFT-2026-v1`. Under the draft protocol, the server-to-client `sampling/createMessage` request method is removed; the recommended way to ask the client to sample from a server handler is to throw <xref:ModelContextProtocol.Protocol.InputRequiredException> and let the SDK emit an <xref:ModelContextProtocol.Protocol.InputRequiredResult> on the wire.
127127

128128
> [!IMPORTANT]
129-
> Calling `SampleAsync` or `AsSamplingChatClient` after negotiating `DRAFT-2026-v1` throws `InvalidOperationException`. Use `InputRequiredException` from your tool/prompt handler instead — it works under both the current protocol (resolved via legacy JSON-RPC + retry on stateful sessions) and the draft protocol (wire-format `InputRequiredResult`, no server-side handler state required).
129+
> `SampleAsync` and `AsSamplingChatClient` throw `InvalidOperationException("Sampling is not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `DRAFT-2026-v1` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `sampling/createMessage` request flow. For code that needs to run on stateless servers — including all `DRAFT-2026-v1` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes.
130130
131131
For example:
132132

@@ -139,7 +139,7 @@ public static string SampleWithMrtr(
139139
// On retry, process the client's sampling response
140140
if (context.Params!.InputResponses?.TryGetValue("llm_call", out var response) is true)
141141
{
142-
var text = response.Deserialize(InputResponse.SamplingResultTypeInfo)?.Content
142+
var text = response.Deserialize(InputResponse.CreateMessageResultJsonTypeInfo)?.Content
143143
.OfType<TextContentBlock>().FirstOrDefault()?.Text;
144144
return $"LLM said: {text}";
145145
}

src/ModelContextProtocol.Core/Protocol/InputResponse.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ public sealed class InputResponse
3131
/// <remarks>
3232
/// Use <see cref="Deserialize{T}"/> with the <c>JsonTypeInfo&lt;T&gt;</c> matching the
3333
/// associated <see cref="InputRequest.Method"/> — for elicitation, sampling, or roots see
34-
/// <see cref="ElicitResultTypeInfo"/>, <see cref="SamplingResultTypeInfo"/>, and
35-
/// <see cref="RootsResultTypeInfo"/>.
34+
/// <see cref="ElicitResultJsonTypeInfo"/>, <see cref="CreateMessageResultJsonTypeInfo"/>, and
35+
/// <see cref="ListRootsResultJsonTypeInfo"/>.
3636
/// </remarks>
3737
[JsonIgnore]
3838
public JsonElement RawValue { get; set; }
@@ -51,21 +51,21 @@ public sealed class InputResponse
5151
/// <see cref="Deserialize{T}"/> when the corresponding <see cref="InputRequest.Method"/> is
5252
/// <see cref="RequestMethods.ElicitationCreate"/>.
5353
/// </summary>
54-
public static JsonTypeInfo<ElicitResult> ElicitResultTypeInfo => McpJsonUtilities.JsonContext.Default.ElicitResult;
54+
public static JsonTypeInfo<ElicitResult> ElicitResultJsonTypeInfo => McpJsonUtilities.JsonContext.Default.ElicitResult;
5555

5656
/// <summary>
5757
/// Gets the <see cref="JsonTypeInfo{T}"/> for <see cref="CreateMessageResult"/>, suitable for use with
5858
/// <see cref="Deserialize{T}"/> when the corresponding <see cref="InputRequest.Method"/> is
5959
/// <see cref="RequestMethods.SamplingCreateMessage"/>.
6060
/// </summary>
61-
public static JsonTypeInfo<CreateMessageResult> SamplingResultTypeInfo => McpJsonUtilities.JsonContext.Default.CreateMessageResult;
61+
public static JsonTypeInfo<CreateMessageResult> CreateMessageResultJsonTypeInfo => McpJsonUtilities.JsonContext.Default.CreateMessageResult;
6262

6363
/// <summary>
6464
/// Gets the <see cref="JsonTypeInfo{T}"/> for <see cref="ListRootsResult"/>, suitable for use with
6565
/// <see cref="Deserialize{T}"/> when the corresponding <see cref="InputRequest.Method"/> is
6666
/// <see cref="RequestMethods.RootsList"/>.
6767
/// </summary>
68-
public static JsonTypeInfo<ListRootsResult> RootsResultTypeInfo => McpJsonUtilities.JsonContext.Default.ListRootsResult;
68+
public static JsonTypeInfo<ListRootsResult> ListRootsResultJsonTypeInfo => McpJsonUtilities.JsonContext.Default.ListRootsResult;
6969

7070
/// <summary>
7171
/// Creates an <see cref="InputResponse"/> from a <see cref="CreateMessageResult"/>.

0 commit comments

Comments
 (0)