Multi Round-Trip Requests (MRTR) (#1458)

This commit is contained in:
Stephen Halter 2026-06-03 16:19:31 -07:00 • committed by GitHub
commit f6dbe4372f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 5832 additions and 103 deletions

View file

@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol.Protocol;
using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ModelContextProtocol.Client;
@ -142,6 +143,8 @@ internal sealed partial class McpClientImpl : McpClient
RequestMethods.SamplingCreateMessage,
async (request, jsonRpcRequest, cancellationToken) =>
{
WarnIfLegacyRequestOnMrtrSession(RequestMethods.SamplingCreateMessage);
// Check if this is a task-augmented request
if (request?.Task is { } taskMetadata)
{
@ -176,10 +179,14 @@ internal sealed partial class McpClientImpl : McpClient
{
requestHandlers.Set(
RequestMethods.SamplingCreateMessage,
(request, _, cancellationToken) => samplingHandler(
request,
request?.ProgressToken is { } token ? new TokenProgress(this, token) : NullProgress.Instance,
cancellationToken),
(request, _, cancellationToken) =>
{
WarnIfLegacyRequestOnMrtrSession(RequestMethods.SamplingCreateMessage);
return samplingHandler(
request,
request?.ProgressToken is { } token ? new TokenProgress(this, token) : NullProgress.Instance,
cancellationToken);
},
McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams,
McpJsonUtilities.JsonContext.Default.CreateMessageResult);
}
@ -192,7 +199,11 @@ internal sealed partial class McpClientImpl : McpClient
{
requestHandlers.Set(
RequestMethods.RootsList,
(request, _, cancellationToken) => rootsHandler(request, cancellationToken),
(request, _, cancellationToken) =>
{
WarnIfLegacyRequestOnMrtrSession(RequestMethods.RootsList);
return rootsHandler(request, cancellationToken);
},
McpJsonUtilities.JsonContext.Default.ListRootsRequestParams,
McpJsonUtilities.JsonContext.Default.ListRootsResult);
@ -209,6 +220,8 @@ internal sealed partial class McpClientImpl : McpClient
RequestMethods.ElicitationCreate,
async (request, jsonRpcRequest, cancellationToken) =>
{
WarnIfLegacyRequestOnMrtrSession(RequestMethods.ElicitationCreate);
// Check if this is a task-augmented request
if (request?.Task is { } taskMetadata)
{
@ -241,6 +254,7 @@ internal sealed partial class McpClientImpl : McpClient
RequestMethods.ElicitationCreate,
async (request, _, cancellationToken) =>
{
WarnIfLegacyRequestOnMrtrSession(RequestMethods.ElicitationCreate);
var result = await elicitationHandler(request, cancellationToken).ConfigureAwait(false);
return ElicitResult.WithDefaults(request, result);
},
@ -547,6 +561,98 @@ internal sealed partial class McpClientImpl : McpClient
/// <inheritdoc/>
public override Task<ClientCompletionDetails> Completion => _sessionHandler.CompletionTask;
/// <inheritdoc/>
private async ValueTask<IDictionary<string, InputResponse>> ResolveInputRequestsAsync(
IDictionary<string, InputRequest> inputRequests,
CancellationToken cancellationToken)
{
// Resolve all input requests concurrently. If any fails, cancel the rest so user-facing
// handlers (sampling/elicitation prompts) don't keep running for a request whose caller
// has already given up, and ensure exceptions from late-completing tasks are observed.
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var keyed = new (string Key, Task<InputResponse> Task)[inputRequests.Count];
int i = 0;
foreach (var kvp in inputRequests)
{
keyed[i++] = (kvp.Key, ResolveInputRequestAsync(kvp.Value, linkedCts.Token));
}
try
{
await Task.WhenAll(Array.ConvertAll(keyed, k => k.Task)).ConfigureAwait(false);
}
catch
{
linkedCts.Cancel();
try
{
await Task.WhenAll(Array.ConvertAll(keyed, k => k.Task)).ConfigureAwait(false);
}
catch
{
// Observed; the original exception is the one we want to surface.
}
throw;
}
var responses = new Dictionary<string, InputResponse>(keyed.Length);
foreach (var (key, task) in keyed)
{
responses[key] = task.Result;
}
return responses;
}
private async Task<InputResponse> ResolveInputRequestAsync(InputRequest inputRequest, CancellationToken cancellationToken)
{
switch (inputRequest.Method)
{
case RequestMethods.SamplingCreateMessage:
if (_options.Handlers.SamplingHandler is { } samplingHandler)
{
var samplingParams = inputRequest.SamplingParams
?? throw new McpException($"Failed to deserialize sampling parameters from MRTR input request.");
var result = await samplingHandler(
samplingParams,
samplingParams.ProgressToken is { } token ? new TokenProgress(this, token) : NullProgress.Instance,
cancellationToken).ConfigureAwait(false);
return InputResponse.FromSamplingResult(result);
}
throw new InvalidOperationException(
$"Server sent a sampling input request, but no {nameof(McpClientHandlers.SamplingHandler)} is registered.");
case RequestMethods.ElicitationCreate:
if (_options.Handlers.ElicitationHandler is { } elicitationHandler)
{
var elicitParams = inputRequest.ElicitationParams
?? throw new McpException($"Failed to deserialize elicitation parameters from MRTR input request.");
var result = await elicitationHandler(elicitParams, cancellationToken).ConfigureAwait(false);
result = ElicitResult.WithDefaults(elicitParams, result);
return InputResponse.FromElicitResult(result);
}
throw new InvalidOperationException(
$"Server sent an elicitation input request, but no {nameof(McpClientHandlers.ElicitationHandler)} is registered.");
case RequestMethods.RootsList:
if (_options.Handlers.RootsHandler is { } rootsHandler)
{
// ListRootsRequest params are optional per the spec, so fall back to an empty params instance.
var rootsParams = inputRequest.RootsParams ?? new ListRootsRequestParams();
var result = await rootsHandler(rootsParams, cancellationToken).ConfigureAwait(false);
return InputResponse.FromRootsResult(result);
}
throw new InvalidOperationException(
$"Server sent a roots list input request, but no {nameof(McpClientHandlers.RootsHandler)} is registered.");
default:
throw new NotSupportedException($"Unsupported input request method: '{inputRequest.Method}'.");
}
}
/// <summary>
/// Asynchronously connects to an MCP server, establishes the transport connection, and completes the initialization handshake.
/// </summary>
@ -718,13 +824,13 @@ internal sealed partial class McpClientImpl : McpClient
}
/// <inheritdoc/>
public override Task<JsonRpcResponse> SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default)
public override async Task<JsonRpcResponse> SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default)
{
// For tools/call requests, attach the cached tool definition to the message context
// so the transport can add custom Mcp-Param-* headers based on x-mcp-header schema annotations.
if (request.Method == RequestMethods.ToolsCall &&
request.Params is System.Text.Json.Nodes.JsonObject paramsObj &&
paramsObj.TryGetPropertyValue("name", out var nameNode) &&
request.Params is System.Text.Json.Nodes.JsonObject paramsObjForHeaders &&
paramsObjForHeaders.TryGetPropertyValue("name", out var nameNode) &&
nameNode?.GetValue<string>() is { } toolName)
{
if (_toolCache.TryGetValue(toolName, out var tool))
@ -739,7 +845,67 @@ internal sealed partial class McpClientImpl : McpClient
}
}
return _sessionHandler.SendRequestAsync(request, cancellationToken);
const int maxRetries = 10;
for (int attempt = 0; attempt <= maxRetries; attempt++)
{
JsonRpcResponse response = await _sessionHandler.SendRequestAsync(request, cancellationToken).ConfigureAwait(false);
// Check if the result is an InputRequiredResult by looking at result_type.
if (response.Result is JsonObject resultObj &&
resultObj.TryGetPropertyValue("resultType", out var resultTypeNode) &&
resultTypeNode?.GetValue<string>() is "input_required")
{
WarnIfInputRequiredResultOnNonMrtrSession(request.Method);
var inputRequiredResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.JsonContext.Default.InputRequiredResult)
?? throw new JsonException("Failed to deserialize InputRequiredResult.");
if (inputRequiredResult.InputRequests is { Count: > 0 } inputRequests)
{
IDictionary<string, InputResponse> inputResponses =
await ResolveInputRequestsAsync(inputRequests, cancellationToken).ConfigureAwait(false);
// Clone the original request params and add inputResponses + requestState for the retry.
var paramsObj = request.Params?.DeepClone() as JsonObject ?? new JsonObject();
paramsObj["inputResponses"] = JsonSerializer.SerializeToNode(
inputResponses, McpJsonUtilities.JsonContext.Default.IDictionaryStringInputResponse);
if (inputRequiredResult.RequestState is { } requestState)
{
paramsObj["requestState"] = requestState;
}
else
{
// Strip any stale requestState carried over from the previous round's clone so
// the server doesn't see a continuation token the current round is not using.
paramsObj.Remove("requestState");
}
request = new JsonRpcRequest { Method = request.Method, Params = paramsObj, Context = request.Context };
}
else if (inputRequiredResult.RequestState is not null)
{
// No input requests but has requestState (e.g., load shedding) - just retry with state.
var paramsObj = request.Params?.DeepClone() as JsonObject ?? new JsonObject();
paramsObj["requestState"] = inputRequiredResult.RequestState;
paramsObj.Remove("inputResponses");
request = new JsonRpcRequest { Method = request.Method, Params = paramsObj, Context = request.Context };
}
else
{
throw new McpException("Server returned an InputRequiredResult without inputRequests or requestState.");
}
continue; // retry with the updated request
}
return response;
}
throw new McpException($"Server returned InputRequiredResult more than {maxRetries} times.");
}
/// <inheritdoc/>
@ -775,6 +941,30 @@ internal sealed partial class McpClientImpl : McpClient
await Completion.ConfigureAwait(false);
}
/// <summary>Logs a warning if the session negotiated MRTR but the server sent a legacy JSON-RPC request.</summary>
private void WarnIfLegacyRequestOnMrtrSession(string method)
{
if (_negotiatedProtocolVersion == McpSessionHandler.DraftProtocolVersion)
{
LogLegacyRequestOnMrtrSession(_endpointName, method);
}
}
/// <summary>Logs a warning if the session did not negotiate MRTR but the server sent an InputRequiredResult.</summary>
private void WarnIfInputRequiredResultOnNonMrtrSession(string method)
{
if (_negotiatedProtocolVersion != McpSessionHandler.DraftProtocolVersion)
{
LogInputRequiredResultOnNonMrtrSession(_endpointName, method, _negotiatedProtocolVersion);
}
}
[LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} received legacy '{Method}' JSON-RPC request on session that negotiated MRTR. The server should use InputRequiredResult instead of sending direct requests.")]
private partial void LogLegacyRequestOnMrtrSession(string endpointName, string method);
[LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} received InputRequiredResult for '{Method}' on session that did not negotiate MRTR (protocol version '{ProtocolVersion}'). The server may not be spec-compliant.")]
private partial void LogInputRequiredResultOnNonMrtrSession(string endpointName, string method, string? protocolVersion);
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} client received server '{ServerInfo}' capabilities: '{Capabilities}'.")]
private partial void LogServerCapabilitiesReceived(string endpointName, string capabilities, string serverInfo);
@ -798,5 +988,4 @@ internal sealed partial class McpClientImpl : McpClient
[LoggerMessage(Level = LogLevel.Warning, Message = "Tool '{ToolName}' excluded from tools/list: {Reason}")]
private partial void LogToolRejected(string toolName, string reason);
}

View file

@ -1,4 +1,4 @@
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Authentication;
using ModelContextProtocol.Protocol;
using System.Diagnostics.CodeAnalysis;
@ -144,6 +144,13 @@ public static partial class McpJsonUtilities
[JsonSerializable(typeof(SubscribeRequestParams))]
[JsonSerializable(typeof(UnsubscribeRequestParams))]
// MCP MRTR (Multi Round-Trip Requests)
[JsonSerializable(typeof(InputRequiredResult))]
[JsonSerializable(typeof(InputRequest))]
[JsonSerializable(typeof(InputResponse))]
[JsonSerializable(typeof(IDictionary<string, InputRequest>))]
[JsonSerializable(typeof(IDictionary<string, InputResponse>))]
// MCP Task Request Params / Results
[JsonSerializable(typeof(McpTask))]
[JsonSerializable(typeof(McpTaskStatus))]

View file

@ -68,7 +68,7 @@ public abstract partial class McpSession : IAsyncDisposable
/// </param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous send operation.</returns>
/// <exception cref="InvalidOperationException">The transport is not connected.</exception>
/// <exception cref="InvalidOperationException">The transport is not connected, or <paramref name="message"/> is a <see cref="JsonRpcRequest"/>. Use <see cref="SendRequestAsync"/> for requests.</exception>
/// <exception cref="ArgumentNullException"><paramref name="message"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <para>

View file

@ -31,6 +31,13 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
/// <summary>The latest version of the protocol supported by this implementation.</summary>
internal const string LatestProtocolVersion = "2025-11-25";
/// <summary>
/// The draft protocol version that enables MRTR (Multi Round-Trip Requests) per SEP-2322.
/// Clients and servers opt in by setting <see cref="McpClientOptions.ProtocolVersion"/>
/// or <see cref="McpServerOptions.ProtocolVersion"/> to this value.
/// </summary>
internal const string DraftProtocolVersion = "DRAFT-2026-v1";
/// <summary>
/// All protocol versions supported by this implementation.
/// Keep in sync with s_supportedProtocolVersions in StreamableHttpHandler.
@ -41,7 +48,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
"2025-03-26",
"2025-06-18",
LatestProtocolVersion,
"DRAFT-2026-v1",
DraftProtocolVersion,
];
/// <summary>
@ -642,6 +649,13 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
{
Throw.IfNull(message);
if (message is JsonRpcRequest request)
{
throw new InvalidOperationException(
$"Cannot send '{request.Method}' request via {nameof(SendMessageAsync)}. " +
$"Use {nameof(SendRequestAsync)} instead to get a correlated response.");
}
cancellationToken.ThrowIfCancellationRequested();
Histogram<double> durationMetric = _isServer ? s_serverOperationDuration : s_clientOperationDuration;

View file

@ -0,0 +1,197 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol;
/// <summary>
/// Represents a server-initiated request that the client must fulfill as part of an MRTR
/// (Multi Round-Trip Request) flow.
/// </summary>
/// <remarks>
/// <para>
/// An <see cref="InputRequest"/> wraps a server-to-client request such as
/// <see cref="RequestMethods.SamplingCreateMessage"/>, <see cref="RequestMethods.ElicitationCreate"/>,
/// or <see cref="RequestMethods.RootsList"/>. It is included in an <see cref="InputRequiredResult"/>
/// when the server needs additional input before it can complete a client-initiated request.
/// </para>
/// <para>
/// The <see cref="Method"/> property identifies the type of request, and the corresponding
/// parameters can be accessed via the typed accessor properties.
/// </para>
/// </remarks>
[Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)]
[JsonConverter(typeof(Converter))]
public sealed class InputRequest
{
/// <summary>
/// Gets or sets the method name identifying the type of this input request.
/// </summary>
/// <remarks>
/// Standard values include:
/// <list type="bullet">
/// <item><term><see cref="RequestMethods.SamplingCreateMessage"/></term><description>A sampling request.</description></item>
/// <item><term><see cref="RequestMethods.ElicitationCreate"/></term><description>An elicitation request.</description></item>
/// <item><term><see cref="RequestMethods.RootsList"/></term><description>A roots list request.</description></item>
/// </list>
/// </remarks>
[JsonPropertyName("method")]
public required string Method { get; set; }
/// <summary>
/// Gets or sets the raw JSON parameters for this input request.
/// </summary>
/// <remarks>
/// Use the typed accessor properties (<see cref="SamplingParams"/>, <see cref="ElicitationParams"/>,
/// <see cref="RootsParams"/>) for convenient strongly-typed access.
/// </remarks>
[JsonPropertyName("params")]
public JsonElement? Params { get; set; }
/// <summary>
/// Gets the parameters as <see cref="CreateMessageRequestParams"/> when <see cref="Method"/>
/// is <see cref="RequestMethods.SamplingCreateMessage"/>.
/// </summary>
/// <returns>The deserialized sampling parameters, or <see langword="null"/> if the method does not match or params are absent.</returns>
[JsonIgnore]
public CreateMessageRequestParams? SamplingParams =>
string.Equals(Method, RequestMethods.SamplingCreateMessage, StringComparison.Ordinal) && Params is { } p
? JsonSerializer.Deserialize(p, McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams)
: null;
/// <summary>
/// Gets the parameters as <see cref="ElicitRequestParams"/> when <see cref="Method"/>
/// is <see cref="RequestMethods.ElicitationCreate"/>.
/// </summary>
/// <returns>The deserialized elicitation parameters, or <see langword="null"/> if the method does not match or params are absent.</returns>
[JsonIgnore]
public ElicitRequestParams? ElicitationParams =>
string.Equals(Method, RequestMethods.ElicitationCreate, StringComparison.Ordinal) && Params is { } p
? JsonSerializer.Deserialize(p, McpJsonUtilities.JsonContext.Default.ElicitRequestParams)
: null;
/// <summary>
/// Gets the parameters as <see cref="ListRootsRequestParams"/> when <see cref="Method"/>
/// is <see cref="RequestMethods.RootsList"/>.
/// </summary>
/// <returns>The deserialized roots list parameters, or <see langword="null"/> if the method does not match or params are absent.</returns>
[JsonIgnore]
public ListRootsRequestParams? RootsParams =>
string.Equals(Method, RequestMethods.RootsList, StringComparison.Ordinal) && Params is { } p
? JsonSerializer.Deserialize(p, McpJsonUtilities.JsonContext.Default.ListRootsRequestParams)
: null;
/// <summary>
/// Creates an <see cref="InputRequest"/> for a sampling request.
/// </summary>
/// <param name="requestParams">The sampling request parameters.</param>
/// <returns>A new <see cref="InputRequest"/> instance.</returns>
public static InputRequest ForSampling(CreateMessageRequestParams requestParams)
{
Throw.IfNull(requestParams);
return new()
{
Method = RequestMethods.SamplingCreateMessage,
Params = JsonSerializer.SerializeToElement(requestParams, McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams),
};
}
/// <summary>
/// Creates an <see cref="InputRequest"/> for an elicitation request.
/// </summary>
/// <param name="requestParams">The elicitation request parameters.</param>
/// <returns>A new <see cref="InputRequest"/> instance.</returns>
public static InputRequest ForElicitation(ElicitRequestParams requestParams)
{
Throw.IfNull(requestParams);
return new()
{
Method = RequestMethods.ElicitationCreate,
Params = JsonSerializer.SerializeToElement(requestParams, McpJsonUtilities.JsonContext.Default.ElicitRequestParams),
};
}
/// <summary>
/// Creates an <see cref="InputRequest"/> for a roots list request.
/// </summary>
/// <param name="requestParams">The roots list request parameters.</param>
/// <returns>A new <see cref="InputRequest"/> instance.</returns>
public static InputRequest ForRootsList(ListRootsRequestParams requestParams)
{
Throw.IfNull(requestParams);
return new()
{
Method = RequestMethods.RootsList,
Params = JsonSerializer.SerializeToElement(requestParams, McpJsonUtilities.JsonContext.Default.ListRootsRequestParams),
};
}
/// <summary>Provides JSON serialization support for <see cref="InputRequest"/>.</summary>
public sealed class Converter : JsonConverter<InputRequest>
{
/// <inheritdoc/>
public override InputRequest? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.StartObject)
{
throw new JsonException("Expected StartObject token.");
}
string? method = null;
JsonElement? parameters = null;
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndObject)
{
break;
}
if (reader.TokenType != JsonTokenType.PropertyName)
{
throw new JsonException("Expected PropertyName token.");
}
string propertyName = reader.GetString()!;
reader.Read();
switch (propertyName)
{
case "method":
method = reader.GetString();
break;
case "params":
parameters = JsonElement.ParseValue(ref reader);
break;
default:
reader.Skip();
break;
}
}
if (method is null)
{
throw new JsonException("InputRequest must have a 'method' property.");
}
return new InputRequest
{
Method = method,
Params = parameters,
};
}
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, InputRequest value, JsonSerializerOptions options)
{
writer.WriteStartObject();
writer.WriteString("method", value.Method);
if (value.Params is { } p)
{
writer.WritePropertyName("params");
p.WriteTo(writer);
}
writer.WriteEndObject();
}
}
}

View file

@ -0,0 +1,109 @@
using System.Diagnostics.CodeAnalysis;
namespace ModelContextProtocol.Protocol;
/// <summary>
/// The exception that is thrown by a server handler to return an <see cref="Protocol.InputRequiredResult"/>
/// to the client, signaling that additional input is needed before the request can be completed.
/// </summary>
/// <remarks>
/// <para>
/// This exception is part of the Multi Round-Trip Requests (MRTR) API. Tool handlers
/// throw this exception to directly control the input-required result payload, including
/// <see cref="Protocol.InputRequiredResult.InputRequests"/> and <see cref="Protocol.InputRequiredResult.RequestState"/>.
/// </para>
/// <para>
/// For stateless servers, this enables multi-round-trip flows without requiring the handler to stay
/// alive between round trips. The server encodes its state in <see cref="Protocol.InputRequiredResult.RequestState"/>
/// and receives it back on retry via <see cref="RequestParams.RequestState"/>.
/// </para>
/// <para>
/// To return a <c>requestState</c>-only response (e.g., for load shedding), omit
/// <see cref="Protocol.InputRequiredResult.InputRequests"/> and set only <see cref="Protocol.InputRequiredResult.RequestState"/>.
/// The client will retry the request with the state echoed back.
/// </para>
/// <para>
/// This exception can only be used when MRTR is supported by the client. Check
/// <see cref="Server.McpServer.IsMrtrSupported"/> before throwing. If thrown when MRTR is not
/// supported, the exception will propagate as a JSON-RPC internal error.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// [McpServerTool, Description("A stateless tool using MRTR")]
/// public static string MyTool(McpServer server, RequestContext&lt;CallToolRequestParams&gt; context)
/// {
/// if (context.Params.RequestState is { } state)
/// {
/// // Retry: process accumulated state and input responses
/// var responses = context.Params.InputResponses;
/// return "Final result";
/// }
///
/// if (!server.IsMrtrSupported)
/// {
/// return "This tool requires MRTR support.";
/// }
///
/// throw new InputRequiredException(
/// inputRequests: new Dictionary&lt;string, InputRequest&gt;
/// {
/// ["user_input"] = InputRequest.ForElicitation(new ElicitRequestParams { ... })
/// },
/// requestState: "encoded-state");
/// }
/// </code>
/// </example>
[Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)]
public class InputRequiredException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="InputRequiredException"/> class
/// with the specified <see cref="Protocol.InputRequiredResult"/>.
/// </summary>
/// <param name="result">The input-required result to return to the client.</param>
public InputRequiredException(InputRequiredResult result)
: base("The server returned an input-required result requiring additional client input.")
{
Throw.IfNull(result);
Result = result;
}
/// <summary>
/// Initializes a new instance of the <see cref="InputRequiredException"/> class
/// with the specified input requests and/or request state.
/// </summary>
/// <param name="inputRequests">
/// Server-initiated requests that the client must fulfill before retrying.
/// Keys are server-assigned identifiers.
/// </param>
/// <param name="requestState">
/// Opaque state to be echoed back by the client when retrying. The client must
/// treat this as an opaque blob and must not inspect or modify it.
/// </param>
/// <exception cref="ArgumentException">
/// Both <paramref name="inputRequests"/> and <paramref name="requestState"/> are <see langword="null"/>.
/// At least one must be provided.
/// </exception>
public InputRequiredException(
IDictionary<string, InputRequest>? inputRequests = null,
string? requestState = null)
: base("The server returned an input-required result requiring additional client input.")
{
if (inputRequests is null && requestState is null)
{
throw new ArgumentException("At least one of inputRequests or requestState must be provided.");
}
Result = new InputRequiredResult
{
InputRequests = inputRequests,
RequestState = requestState,
};
}
/// <summary>
/// Gets the input-required result to return to the client.
/// </summary>
public InputRequiredResult Result { get; }
}

View file

@ -0,0 +1,65 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol;
/// <summary>
/// Represents an input-required result sent by the server to indicate that additional input is needed
/// before the request can be completed.
/// </summary>
/// <remarks>
/// <para>
/// An <see cref="InputRequiredResult"/> is returned in response to a client-initiated request when
/// the server needs the client to fulfill one or more server-initiated requests before it can produce
/// a final result. Per SEP-2322 the wire format is valid for <see cref="RequestMethods.ToolsCall"/>,
/// <see cref="RequestMethods.PromptsGet"/>, and <c>resources/read</c>; this SDK wires the MRTR
/// interceptor into all three methods.
/// </para>
/// <para>
/// At least one of <see cref="InputRequests"/> or <see cref="RequestState"/> must be present.
/// </para>
/// <para>
/// This type is part of the Multi Round-Trip Requests (MRTR) mechanism defined in SEP-2322.
/// </para>
/// </remarks>
[Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)]
public sealed class InputRequiredResult : Result
{
/// <summary>
/// Initializes a new instance of the <see cref="InputRequiredResult"/> class.
/// </summary>
public InputRequiredResult()
{
ResultType = "input_required";
}
/// <summary>
/// Gets or sets the server-initiated requests that the client must fulfill before retrying the original request.
/// </summary>
/// <remarks>
/// <para>
/// The keys are server-assigned identifiers. The client must include a response for each key in the
/// <see cref="RequestParams.InputResponses"/> map when retrying the original request.
/// </para>
/// </remarks>
[JsonPropertyName("inputRequests")]
public IDictionary<string, InputRequest>? InputRequests { get; set; }
/// <summary>
/// Gets or sets opaque state to be echoed back by the client when retrying the original request.
/// </summary>
/// <remarks>
/// <para>
/// The client must treat this as an opaque blob and must not inspect, parse, modify, or make
/// any assumptions about the contents. If present, the client must include this value in the
/// <see cref="RequestParams.RequestState"/> property when retrying the original request.
/// </para>
/// <para>
/// Servers may encode request state in any format (e.g., plain JSON, base64-encoded JSON,
/// encrypted JWT, serialized binary). If the state contains sensitive data, servers should
/// encrypt it to ensure confidentiality and integrity.
/// </para>
/// </remarks>
[JsonPropertyName("requestState")]
public string? RequestState { get; set; }
}

View file

@ -0,0 +1,128 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
namespace ModelContextProtocol.Protocol;
/// <summary>
/// Represents a client's response to a server-initiated <see cref="InputRequest"/> as part of an MRTR
/// (Multi Round-Trip Request) flow.
/// </summary>
/// <remarks>
/// <para>
/// An <see cref="InputResponse"/> wraps the result of a server-to-client request such as
/// <see cref="CreateMessageResult"/>, <see cref="ElicitResult"/>, or <see cref="ListRootsResult"/>.
/// The type of the inner response corresponds to the <see cref="InputRequest.Method"/> of the
/// associated input request.
/// </para>
/// <para>
/// The input response does not carry its own type discriminator in JSON. The type is determined by
/// the corresponding <see cref="InputRequest.Method"/> key in the <see cref="InputRequiredResult.InputRequests"/> map.
/// </para>
/// </remarks>
[Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)]
[JsonConverter(typeof(Converter))]
public sealed class InputResponse
{
/// <summary>
/// Gets or sets the raw JSON element representing the response.
/// </summary>
/// <remarks>
/// Use <see cref="Deserialize{T}"/> with the <c>JsonTypeInfo&lt;T&gt;</c> matching the
/// associated <see cref="InputRequest.Method"/> - for elicitation, sampling, or roots see
/// <see cref="ElicitResultJsonTypeInfo"/>, <see cref="CreateMessageResultJsonTypeInfo"/>, and
/// <see cref="ListRootsResultJsonTypeInfo"/>.
/// </remarks>
[JsonIgnore]
public JsonElement RawValue { get; set; }
/// <summary>
/// Deserializes the raw value to the specified result type.
/// </summary>
/// <typeparam name="T">The type to deserialize to (e.g., <see cref="CreateMessageResult"/>, <see cref="ElicitResult"/>).</typeparam>
/// <param name="typeInfo">The JSON type information for <typeparamref name="T"/>.</param>
/// <returns>The deserialized result, or <see langword="null"/> if deserialization fails.</returns>
public T? Deserialize<T>(System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> typeInfo) =>
JsonSerializer.Deserialize(RawValue, typeInfo);
/// <summary>
/// Gets the <see cref="JsonTypeInfo{T}"/> for <see cref="ElicitResult"/>, suitable for use with
/// <see cref="Deserialize{T}"/> when the corresponding <see cref="InputRequest.Method"/> is
/// <see cref="RequestMethods.ElicitationCreate"/>.
/// </summary>
public static JsonTypeInfo<ElicitResult> ElicitResultJsonTypeInfo => McpJsonUtilities.JsonContext.Default.ElicitResult;
/// <summary>
/// Gets the <see cref="JsonTypeInfo{T}"/> for <see cref="CreateMessageResult"/>, suitable for use with
/// <see cref="Deserialize{T}"/> when the corresponding <see cref="InputRequest.Method"/> is
/// <see cref="RequestMethods.SamplingCreateMessage"/>.
/// </summary>
public static JsonTypeInfo<CreateMessageResult> CreateMessageResultJsonTypeInfo => McpJsonUtilities.JsonContext.Default.CreateMessageResult;
/// <summary>
/// Gets the <see cref="JsonTypeInfo{T}"/> for <see cref="ListRootsResult"/>, suitable for use with
/// <see cref="Deserialize{T}"/> when the corresponding <see cref="InputRequest.Method"/> is
/// <see cref="RequestMethods.RootsList"/>.
/// </summary>
public static JsonTypeInfo<ListRootsResult> ListRootsResultJsonTypeInfo => McpJsonUtilities.JsonContext.Default.ListRootsResult;
/// <summary>
/// Creates an <see cref="InputResponse"/> from a <see cref="CreateMessageResult"/>.
/// </summary>
/// <param name="result">The sampling result.</param>
/// <returns>A new <see cref="InputResponse"/> instance.</returns>
public static InputResponse FromSamplingResult(CreateMessageResult result)
{
Throw.IfNull(result);
return new()
{
RawValue = JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.CreateMessageResult),
};
}
/// <summary>
/// Creates an <see cref="InputResponse"/> from an <see cref="ElicitResult"/>.
/// </summary>
/// <param name="result">The elicitation result.</param>
/// <returns>A new <see cref="InputResponse"/> instance.</returns>
public static InputResponse FromElicitResult(ElicitResult result)
{
Throw.IfNull(result);
return new()
{
RawValue = JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.ElicitResult),
};
}
/// <summary>
/// Creates an <see cref="InputResponse"/> from a <see cref="ListRootsResult"/>.
/// </summary>
/// <param name="result">The roots list result.</param>
/// <returns>A new <see cref="InputResponse"/> instance.</returns>
public static InputResponse FromRootsResult(ListRootsResult result)
{
Throw.IfNull(result);
return new()
{
RawValue = JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.ListRootsResult),
};
}
/// <summary>Provides JSON serialization support for <see cref="InputResponse"/>.</summary>
public sealed class Converter : JsonConverter<InputResponse>
{
/// <inheritdoc/>
public override InputResponse? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var element = JsonElement.ParseValue(ref reader);
return new InputResponse { RawValue = element };
}
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, InputResponse value, JsonSerializerOptions options)
{
value.RawValue.WriteTo(writer);
}
}
}

View file

@ -74,4 +74,15 @@ public sealed class JsonRpcMessageContext
/// </para>
/// </remarks>
public IDictionary<string, object?>? Items { get; set; }
/// <summary>
/// Gets or sets the protocol version from the transport-level header (e.g. <c>Mcp-Protocol-Version</c>)
/// that accompanied this JSON-RPC message.
/// </summary>
/// <remarks>
/// In stateless Streamable HTTP mode, the protocol version cannot be negotiated via the <c>initialize</c>
/// handshake because each request creates a new server instance. This property allows the transport layer
/// to flow the protocol version header so the server can determine client capabilities.
/// </remarks>
public string? ProtocolVersion { get; set; }
}

View file

@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
@ -25,6 +26,52 @@ public abstract class RequestParams
[JsonPropertyName("_meta")]
public JsonObject? Meta { get; set; }
/// <summary>
/// Gets or sets the responses to server-initiated input requests from a previous <see cref="InputRequiredResult"/>.
/// </summary>
/// <remarks>
/// <para>
/// This property is populated when retrying a request after receiving an <see cref="InputRequiredResult"/>.
/// Each key corresponds to a key from the <see cref="InputRequiredResult.InputRequests"/> map, and
/// the value is the client's response to that input request.
/// </para>
/// </remarks>
[Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)]
[JsonIgnore]
public IDictionary<string, InputResponse>? InputResponses
{
get => InputResponsesCore;
set => InputResponsesCore = value;
}
// See ExperimentalInternalPropertyTests.cs before modifying this property.
[JsonInclude]
[JsonPropertyName("inputResponses")]
internal IDictionary<string, InputResponse>? InputResponsesCore { get; set; }
/// <summary>
/// Gets or sets opaque request state echoed back from a previous <see cref="InputRequiredResult"/>.
/// </summary>
/// <remarks>
/// <para>
/// This property is populated when retrying a request after receiving an <see cref="InputRequiredResult"/>
/// that included a <see cref="InputRequiredResult.RequestState"/> value. The client must echo back the
/// exact value without modification.
/// </para>
/// </remarks>
[Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)]
[JsonIgnore]
public string? RequestState
{
get => RequestStateCore;
set => RequestStateCore = value;
}
// See ExperimentalInternalPropertyTests.cs before modifying this property.
[JsonInclude]
[JsonPropertyName("requestState")]
internal string? RequestStateCore { get; set; }
/// <summary>
/// Gets the opaque token that will be attached to any subsequent progress notifications.
/// </summary>

View file

@ -21,4 +21,18 @@ public abstract class Result
/// </remarks>
[JsonPropertyName("_meta")]
public JsonObject? Meta { get; set; }
/// <summary>
/// Gets or sets the type of the result, which allows the client to determine how to parse the result object.
/// </summary>
/// <remarks>
/// <para>
/// When absent or set to <c>"complete"</c>, the result is a normal completed response.
/// When set to <c>"input_required"</c>, the result is an <see cref="InputRequiredResult"/> indicating
/// that additional input is needed before the request can be completed.
/// </para>
/// </remarks>
/// <value>Defaults to <see langword="null"/>, which is equivalent to <c>"complete"</c>.</value>
[JsonPropertyName("resultType")]
public string? ResultType { get; set; }
}

View file

@ -1,5 +1,6 @@
using ModelContextProtocol.Protocol;
using System.Diagnostics;
using ModelContextProtocol.Protocol;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ModelContextProtocol.Server;
@ -15,6 +16,14 @@ internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport
public override IServiceProvider? Services => server.Services;
public override LoggingLevel? LoggingLevel => server.LoggingLevel;
/// <summary>
/// Gets or sets the MRTR context for the current request, if any.
/// Set by <see cref="McpServerImpl.CreateDestinationBoundServer"/> when an MRTR-aware handler invocation is in progress.
/// </summary>
internal MrtrContext? ActiveMrtrContext { get; set; }
public override bool IsMrtrSupported => server.IsMrtrSupported;
public override ValueTask DisposeAsync() => server.DisposeAsync();
public override IAsyncDisposable RegisterNotificationHandler(string method, Func<JsonRpcNotification, CancellationToken, ValueTask> handler) => server.RegisterNotificationHandler(method, handler);
@ -39,6 +48,16 @@ internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport
public override Task<JsonRpcResponse> SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default)
{
// When an MRTR context is active, intercept server-to-client requests (sampling, elicitation, roots)
// and route them through the MRTR mechanism instead of sending them over the wire.
// Task-augmented requests (SampleAsTaskAsync/ElicitAsTaskAsync) have a "task" property on their params
// and expect a CreateTaskResult response, so they must bypass MRTR and go over the wire.
if (ActiveMrtrContext is { } mrtrContext &&
!(request.Params is JsonObject paramsObj && paramsObj.ContainsKey("task")))
{
return SendRequestViaMrtrAsync(mrtrContext, request, cancellationToken);
}
if (request.Context is not null)
{
throw new ArgumentException("Only transports can provide a JsonRpcMessageContext.");
@ -51,4 +70,23 @@ internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport
return server.SendRequestAsync(request, cancellationToken);
}
private static async Task<JsonRpcResponse> SendRequestViaMrtrAsync(
MrtrContext mrtrContext, JsonRpcRequest request, CancellationToken cancellationToken)
{
var inputRequest = new InputRequest
{
Method = request.Method,
Params = request.Params is { } paramsNode
? JsonSerializer.Deserialize(paramsNode, McpJsonUtilities.JsonContext.Default.JsonElement)
: null,
};
var inputResponse = await mrtrContext.RequestInputAsync(inputRequest, cancellationToken).ConfigureAwait(false);
return new JsonRpcResponse
{
Id = request.Id,
Result = JsonSerializer.SerializeToNode(inputResponse.RawValue, McpJsonUtilities.JsonContext.Default.JsonElement),
};
}
}

View file

@ -64,6 +64,25 @@ public abstract partial class McpServer : McpSession
/// <summary>Gets the last logging level set by the client, or <see langword="null"/> if it's never been set.</summary>
public abstract LoggingLevel? LoggingLevel { get; }
/// <summary>
/// Gets a value indicating whether the connected client supports Multi Round-Trip Requests (MRTR).
/// </summary>
/// <remarks>
/// <para>
/// When this property returns <see langword="true"/>, tool handlers can throw
/// <see cref="Protocol.InputRequiredException"/> to return an <see cref="Protocol.InputRequiredResult"/>
/// with <see cref="Protocol.InputRequiredResult.InputRequests"/> and/or
/// <see cref="Protocol.InputRequiredResult.RequestState"/> to the client.
/// </para>
/// <para>
/// When this property returns <see langword="false"/>, tool handlers should provide a fallback
/// experience (for example, returning a text message explaining that the client does not support
/// the required feature) instead of throwing <see cref="Protocol.InputRequiredException"/>.
/// </para>
/// </remarks>
[Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)]
public virtual bool IsMrtrSupported => false;
/// <summary>
/// Runs the server, listening for and handling client requests.
/// </summary>

View file

@ -2,8 +2,10 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol.Protocol;
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization.Metadata;
namespace ModelContextProtocol.Server;
@ -27,6 +29,13 @@ internal sealed partial class McpServerImpl : McpServer
private readonly McpSessionHandler _sessionHandler;
private readonly SemaphoreSlim _disposeLock = new(1, 1);
private readonly McpTaskCancellationTokenProvider? _taskCancellationTokenProvider;
private readonly ConcurrentDictionary<string, MrtrContinuation> _mrtrContinuations = new();
private readonly ConcurrentDictionary<RequestId, MrtrContext> _mrtrContextsByRequestId = new();
// Track MRTR handler tasks using the same inFlightCount + TCS pattern as
// McpSessionHandler.ProcessMessagesCoreAsync. Starts at 1 for DisposeAsync itself.
private int _mrtrInFlightCount = 1;
private readonly TaskCompletionSource<bool> _allMrtrHandlersCompleted = new(TaskCreationOptions.RunContinuationsAsynchronously);
private ClientCapabilities? _clientCapabilities;
private Implementation? _clientInfo;
@ -91,6 +100,7 @@ internal sealed partial class McpServerImpl : McpServer
ConfigureLogging(options);
ConfigureCompletion(options);
ConfigureExperimentalAndExtensions(options);
ConfigureMrtr();
// Register any notification handlers that were provided.
if (options.Handlers.NotificationHandlers is { } notificationHandlers)
@ -210,9 +220,35 @@ internal sealed partial class McpServerImpl : McpServer
_disposed = true;
// Dispose the session handler first - cancels message processing and waits for all
// in-flight request handlers (including retries in AwaitMrtrHandlerAsync) to complete.
// After this returns, no new requests can be processed and no new MRTR continuations
// can be created, so _mrtrContinuations is effectively frozen.
_taskCancellationTokenProvider?.Dispose();
_disposables.ForEach(d => d());
await _sessionHandler.DisposeAsync().ConfigureAwait(false);
// Cancel all orphaned MRTR handlers still suspended in continuations (waiting for
// retries that will never arrive now that the session handler is disposed).
int cancelledCount = _mrtrContinuations.Count;
foreach (var continuation in _mrtrContinuations.Values)
{
continuation.CancelHandler();
}
if (cancelledCount > 0)
{
MrtrContinuationsCancelled(cancelledCount);
}
// Wait for all MRTR handler tasks to complete using the same inFlightCount + TCS
// pattern as McpSessionHandler.ProcessMessagesCoreAsync. The count started at 1
// (for DisposeAsync itself); decrementing it here triggers the drain if handlers
// are still in flight. ObserveHandlerCompletionAsync decrements for each handler.
if (Interlocked.Decrement(ref _mrtrInFlightCount) != 0)
{
await _allMrtrHandlersCompleted.Task.ConfigureAwait(false);
}
}
private void ConfigureInitialize(McpServerOptions options)
@ -231,7 +267,8 @@ internal sealed partial class McpServerImpl : McpServer
// Otherwise, try to use whatever the client requested as long as it's supported.
// If it's not supported, fall back to the latest supported version.
string? protocolVersion = options.ProtocolVersion;
protocolVersion ??= request?.ProtocolVersion is string clientProtocolVersion && McpSessionHandler.SupportedProtocolVersions.Contains(clientProtocolVersion) ?
protocolVersion ??= request?.ProtocolVersion is string clientProtocolVersion &&
McpSessionHandler.SupportedProtocolVersions.Contains(clientProtocolVersion) ?
clientProtocolVersion :
McpSessionHandler.LatestProtocolVersion;
@ -725,7 +762,7 @@ internal sealed partial class McpServerImpl : McpServer
McpErrorCode.InvalidParams);
}
// Task augmentation requested - return CreateTaskResult
// Task augmentation requested with immediate creation
return await ExecuteToolAsTaskAsync(tool, request, taskMetadata, taskStore, sendNotifications, cancellationToken).ConfigureAwait(false);
}
@ -774,9 +811,18 @@ internal sealed partial class McpServerImpl : McpServer
}
catch (Exception e)
{
ToolCallError(request.Params?.Name ?? string.Empty, e);
// Skip logging for OperationCanceledException when the cancellation token
// is signaled - tool handler cancellation is an expected lifecycle event
// (client request cancellation, session shutdown, MRTR teardown), not a
// tool error.
// Skip logging for InputRequiredException - it's normal MRTR control flow,
// not an error (tools throw it to signal an InputRequiredResult).
if (!(e is OperationCanceledException && cancellationToken.IsCancellationRequested) && e is not InputRequiredException)
{
ToolCallError(request.Params?.Name ?? string.Empty, e);
}
if ((e is OperationCanceledException && cancellationToken.IsCancellationRequested) || e is McpProtocolException)
if ((e is OperationCanceledException && cancellationToken.IsCancellationRequested) || e is McpProtocolException || e is InputRequiredException)
{
throw;
}
@ -990,7 +1036,7 @@ internal sealed partial class McpServerImpl : McpServer
{
return _servicesScopePerRequest ?
InvokeScopedAsync(handler, args, jsonRpcRequest, cancellationToken) :
handler(new(new DestinationBoundMcpServer(this, jsonRpcRequest.Context?.RelatedTransport), jsonRpcRequest, args), cancellationToken);
handler(new(CreateDestinationBoundServer(jsonRpcRequest), jsonRpcRequest, args), cancellationToken);
async ValueTask<TResult> InvokeScopedAsync(
McpRequestHandler<TParams, TResult> handler,
@ -1002,7 +1048,7 @@ internal sealed partial class McpServerImpl : McpServer
try
{
return await handler(
new RequestContext<TParams>(new DestinationBoundMcpServer(this, jsonRpcRequest.Context?.RelatedTransport), jsonRpcRequest, args)
new RequestContext<TParams>(CreateDestinationBoundServer(jsonRpcRequest), jsonRpcRequest, args)
{
Services = scope?.ServiceProvider ?? Services,
},
@ -1018,6 +1064,18 @@ internal sealed partial class McpServerImpl : McpServer
}
}
private DestinationBoundMcpServer CreateDestinationBoundServer(JsonRpcRequest jsonRpcRequest)
{
var server = new DestinationBoundMcpServer(this, jsonRpcRequest.Context?.RelatedTransport);
if (_mrtrContextsByRequestId.TryRemove(jsonRpcRequest.Id, out var mrtrContext))
{
server.ActiveMrtrContext = mrtrContext;
}
return server;
}
private void SetHandler<TParams, TResult>(
string method,
McpRequestHandler<TParams, TResult> handler,
@ -1106,6 +1164,436 @@ internal sealed partial class McpServerImpl : McpServer
_ => Protocol.LoggingLevel.Emergency,
};
/// <summary>
/// Checks whether the negotiated protocol version enables MRTR per SEP-2322 (DRAFT-2026-v1).
/// </summary>
internal bool ClientSupportsMrtr() =>
_negotiatedProtocolVersion == McpSessionHandler.DraftProtocolVersion;
/// <summary>
/// Returns <see langword="true"/> when the session is stateful - the same server instance handles
/// subsequent requests on the same session. The legacy backcompat resolver in
/// <see cref="InvokeWithInputRequiredResultHandlingAsync"/> needs a stateful session so it can send
/// <c>elicitation/create</c> / <c>sampling/createMessage</c> / <c>roots/list</c> to the client and
/// retry the handler with the responses.
/// </summary>
internal bool IsStatefulSession() =>
_sessionTransport is not StreamableHttpServerTransport { Stateless: true };
/// <inheritdoc />
public override bool IsMrtrSupported => ClientSupportsMrtr() || IsStatefulSession();
/// <summary>
/// Invokes a handler and catches <see cref="InputRequiredException"/> to convert it to an
/// <see cref="InputRequiredResult"/> JSON response. When MRTR is negotiated or the server is stateless,
/// the result is serialized directly. Otherwise, input requests are resolved via standard JSON-RPC
/// calls (elicitation, sampling, roots) and the handler is retried with the responses - allowing
/// MRTR-native tools to work transparently with clients that don't support MRTR.
/// </summary>
private async Task<JsonNode?> InvokeWithInputRequiredResultHandlingAsync(
Func<JsonRpcRequest, CancellationToken, Task<JsonNode?>> handler,
JsonRpcRequest request,
CancellationToken cancellationToken)
{
const int MaxRetries = 10;
// In stateless mode, pick up the negotiated draft protocol version from the
// transport-provided request context because there is no long-lived initialize handshake state.
if (_negotiatedProtocolVersion is null &&
request.Context?.ProtocolVersion is { } headerProtocolVersion)
{
_negotiatedProtocolVersion = headerProtocolVersion;
}
for (int retry = 0; ; retry++)
{
try
{
return await handler(request, cancellationToken).ConfigureAwait(false);
}
catch (InputRequiredException ex)
{
// If the client natively supports MRTR, serialize and return directly -
// the client will drive the retry loop.
if (ClientSupportsMrtr())
{
return SerializeInputRequiredResult(ex.Result);
}
// In stateless mode without MRTR, the server can't resolve input requests via
// JSON-RPC (no persistent session for server-to-client requests), and the client
// won't recognize the InputRequiredResult. This is the one unsupported configuration.
// TODO(stateless-draft): When DRAFT-2026-v1 becomes stateless-only, the IsStatefulSession() gate collapses - the stateful path will only matter for legacy clients on the current protocol.
if (!IsStatefulSession())
{
throw new McpException(
"A tool handler returned an incomplete result, but the server is stateless and the client does not support MRTR. " +
"MRTR-native tools require either an MRTR-capable client or a stateful server for backward-compatible resolution.", ex);
}
// Backcompat: resolve input requests via standard JSON-RPC calls and retry the handler.
if (ex.Result.InputRequests is not { Count: > 0 } inputRequests)
{
throw new McpException(
"A tool handler returned an incomplete result without input requests, and the client does not support MRTR.", ex);
}
if (retry >= MaxRetries)
{
throw new McpException(
$"MRTR-native tool exceeded {MaxRetries} retry rounds without completing.", ex);
}
// Resolve each input request by sending the corresponding JSON-RPC call to the client.
// Route the outgoing requests via the same DestinationBoundMcpServer used for normal tool
// handlers, so they go through the POST's response stream (RelatedTransport) rather than
// the session-level transport. Without this, the messages can race with the client's GET
// stream startup and be silently dropped by StreamableHttpServerTransport.SendMessageAsync
// when no GET request has arrived yet.
var destinationServer = CreateDestinationBoundServer(request);
var inputResponses = await ResolveInputRequestsAsync(destinationServer, inputRequests, cancellationToken).ConfigureAwait(false);
// Reconstruct request params with inputResponses and requestState for the retry.
var paramsObj = request.Params?.DeepClone() as JsonObject ?? new JsonObject();
paramsObj["inputResponses"] = JsonSerializer.SerializeToNode(
(IDictionary<string, InputResponse>)inputResponses, McpJsonUtilities.JsonContext.Default.IDictionaryStringInputResponse);
if (ex.Result.RequestState is { } requestState)
{
paramsObj["requestState"] = requestState;
}
else
{
// Strip any stale requestState carried over from the previous round's clone so
// the next tool invocation doesn't see a continuation token the current round is not using.
paramsObj.Remove("requestState");
}
request = new JsonRpcRequest
{
Id = request.Id,
Method = request.Method,
Params = paramsObj,
Context = request.Context,
};
}
}
}
/// <summary>
/// Resolves a batch of MRTR input requests concurrently by dispatching each as a standard
/// JSON-RPC request to the client. The requests are routed via <paramref name="destinationServer"/>
/// so they go out through the POST's response stream (matching the behavior of tool-initiated
/// server-to-client requests like <c>server.SampleAsync</c>) and avoid racing with the client's
/// GET stream startup. On the first failure all remaining handlers are cancelled so user-facing
/// flows (sampling/elicitation prompts) don't keep running once the caller has given up, and
/// exceptions from late-completing tasks are observed before the original exception is rethrown.
/// </summary>
private static async Task<IDictionary<string, InputResponse>> ResolveInputRequestsAsync(
McpServer destinationServer,
IDictionary<string, InputRequest> inputRequests,
CancellationToken cancellationToken)
{
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var keyed = new (string Key, Task<InputResponse> Task)[inputRequests.Count];
int i = 0;
foreach (var kvp in inputRequests)
{
keyed[i++] = (kvp.Key, ResolveInputRequestAsync(destinationServer, kvp.Value, linkedCts.Token));
}
try
{
await Task.WhenAll(Array.ConvertAll(keyed, k => k.Task)).ConfigureAwait(false);
}
catch
{
linkedCts.Cancel();
try
{
await Task.WhenAll(Array.ConvertAll(keyed, k => k.Task)).ConfigureAwait(false);
}
catch
{
// Observed; the original exception is the one we want to surface.
}
throw;
}
var responses = new Dictionary<string, InputResponse>(keyed.Length);
foreach (var (key, task) in keyed)
{
responses[key] = task.Result;
}
return responses;
}
/// <summary>
/// Resolves a single MRTR <see cref="InputRequest"/> by dispatching it as a standard JSON-RPC
/// request to the client via <paramref name="destinationServer"/>. This is the server-side mirror
/// of the client's input resolution logic, used for backward compatibility when the client doesn't
/// support MRTR.
/// </summary>
private static async Task<InputResponse> ResolveInputRequestAsync(McpServer destinationServer, InputRequest inputRequest, CancellationToken cancellationToken)
{
switch (inputRequest.Method)
{
case RequestMethods.ElicitationCreate:
var elicitParams = inputRequest.ElicitationParams
?? throw new McpException("Failed to deserialize elicitation parameters from MRTR input request.");
var elicitResult = await destinationServer.ElicitAsync(elicitParams, cancellationToken).ConfigureAwait(false);
return InputResponse.FromElicitResult(elicitResult);
case RequestMethods.SamplingCreateMessage:
var samplingParams = inputRequest.SamplingParams
?? throw new McpException("Failed to deserialize sampling parameters from MRTR input request.");
var samplingResult = await destinationServer.SampleAsync(samplingParams, cancellationToken).ConfigureAwait(false);
return InputResponse.FromSamplingResult(samplingResult);
case RequestMethods.RootsList:
var rootsParams = inputRequest.RootsParams ?? new ListRootsRequestParams();
var rootsResult = await destinationServer.RequestRootsAsync(rootsParams, cancellationToken).ConfigureAwait(false);
return InputResponse.FromRootsResult(rootsResult);
default:
throw new McpException($"Unsupported input request method: '{inputRequest.Method}'.");
}
}
private static JsonNode? SerializeInputRequiredResult(InputRequiredResult inputRequiredResult) =>
JsonSerializer.SerializeToNode(inputRequiredResult, McpJsonUtilities.JsonContext.Default.InputRequiredResult);
/// <summary>
/// Wraps MRTR-eligible request handlers so that when a handler calls ElicitAsync/SampleAsync/RequestRootsAsync,
/// an <see cref="InputRequiredResult"/> is returned early and the handler is suspended until the retry arrives.
/// </summary>
private void ConfigureMrtr()
{
// Wrap all methods that may trigger MRTR (server calling ElicitAsync/SampleAsync/RequestRootsAsync
// during handler execution). These methods may produce InputRequiredResult if the handler needs input.
WrapHandlerWithMrtr(RequestMethods.ToolsCall);
WrapHandlerWithMrtr(RequestMethods.PromptsGet);
WrapHandlerWithMrtr(RequestMethods.ResourcesRead);
}
/// <summary>
/// Replaces an existing request handler entry with an MRTR-aware wrapper that supports
/// handler suspension and <see cref="InputRequiredResult"/> responses.
/// </summary>
private void WrapHandlerWithMrtr(string method)
{
if (!_requestHandlers.TryGetValue(method, out var originalHandler))
{
return;
}
_requestHandlers[method] = async (request, cancellationToken) =>
{
// In stateless mode, each request creates a new server instance that never saw the
// initialize handshake, so _negotiatedProtocolVersion is null. Pick it up from the
// Mcp-Protocol-Version header that the transport layer flowed via JsonRpcMessageContext.
if (_negotiatedProtocolVersion is null &&
request.Context?.ProtocolVersion is { } headerProtocolVersion)
{
_negotiatedProtocolVersion = headerProtocolVersion;
}
// Check for MRTR retry: if requestState is present, look up the continuation.
if (request.Params is JsonObject paramsObj &&
paramsObj.TryGetPropertyValue("requestState", out var requestStateNode) &&
requestStateNode?.GetValueKind() == JsonValueKind.String &&
requestStateNode.GetValue<string>() is { } requestState)
{
if (_mrtrContinuations.TryRemove(requestState, out var existingContinuation))
{
// Implicit MRTR retry: resume the suspended handler with client responses.
IDictionary<string, InputResponse>? inputResponses = null;
if (paramsObj.TryGetPropertyValue("inputResponses", out var responsesNode) && responsesNode is not null)
{
inputResponses = JsonSerializer.Deserialize(responsesNode, McpJsonUtilities.JsonContext.Default.IDictionaryStringInputResponse);
}
var exchange = existingContinuation.PendingExchange!;
var nextExchangeTask = existingContinuation.MrtrContext.ResetForNextExchange(exchange);
if (inputResponses is not null &&
inputResponses.TryGetValue(exchange.Key, out var response))
{
if (!exchange.ResponseTcs.TrySetResult(response))
{
throw new McpProtocolException(
$"MRTR exchange '{exchange.Key}' was already completed (possibly cancelled).",
McpErrorCode.InternalError);
}
}
else
{
if (!exchange.ResponseTcs.TrySetException(
new McpProtocolException($"Missing input response for key '{exchange.Key}'.", McpErrorCode.InvalidParams)))
{
throw new McpProtocolException(
$"MRTR exchange '{exchange.Key}' was already completed (possibly cancelled).",
McpErrorCode.InternalError);
}
}
return await AwaitMrtrHandlerAsync(
existingContinuation.HandlerTask, existingContinuation, nextExchangeTask, cancellationToken).ConfigureAwait(false);
}
// Explicit MRTR retry or invalid requestState: no continuation found.
// Fall through to the standard MRTR-aware invocation path below. The retry data
// (inputResponses, requestState) is already in the deserialized request params
// for low-level handlers to access, and the MrtrContext will be set up for
// high-level handlers that call ElicitAsync/SampleAsync.
}
// Implicit MRTR (handler suspension across ElicitAsync/SampleAsync) emits
// InputRequiredResult on the wire, which only DRAFT-2026-v1 clients understand,
// and requires the same server instance to handle the retry (stateful session).
// For all other cases - legacy clients, stateless sessions - fall through to the
// exception-based path, which transparently resolves InputRequiredException via
// legacy JSON-RPC requests when the client doesn't speak MRTR.
if (!ClientSupportsMrtr() || !IsStatefulSession())
{
return await InvokeWithInputRequiredResultHandlingAsync(originalHandler, request, cancellationToken).ConfigureAwait(false);
}
// Start a new MRTR-aware handler invocation.
var mrtrContext = new MrtrContext();
// Create a long-lived CTS for the handler that survives across retries.
// The original request's combinedCts will be disposed when this lambda returns,
// breaking the cancellation chain. This CTS keeps the handler cancellable.
// Like Kestrel's HttpContext.RequestAborted, the CTS is never disposed - Cancel()
// is thread-safe with itself, and not disposing avoids deadlock risks from
// calling Cancel/Dispose inside locks or Interlocked guards.
var handlerCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
// Store the MrtrContext so CreateDestinationBoundServer can pick it up and set it
// on the per-request DestinationBoundMcpServer. This is picked up synchronously
// before any await, so the finally cleanup is safe.
_mrtrContextsByRequestId[request.Id] = mrtrContext;
Task<JsonNode?> handlerTask;
try
{
handlerTask = originalHandler(request, handlerCts.Token);
}
finally
{
_mrtrContextsByRequestId.TryRemove(request.Id, out _);
}
// Wrap handler state into a continuation for lifecycle management across retries.
var continuation = new MrtrContinuation(handlerCts, handlerTask, mrtrContext);
// Track the handler task for lifecycle management. The observer logs unhandled
// exceptions and decrements _mrtrInFlightCount when the handler completes,
// mirroring how McpSessionHandler tracks in-flight handlers.
Interlocked.Increment(ref _mrtrInFlightCount);
_ = ObserveHandlerCompletionAsync(handlerTask);
return await AwaitMrtrHandlerAsync(
handlerTask, continuation, mrtrContext.InitialExchangeTask, cancellationToken).ConfigureAwait(false);
};
}
/// <summary>
/// Awaits the outcome of an MRTR-enabled handler invocation.
/// If the handler completes, returns its result. If an exchange arrives (handler needs input),
/// builds and returns an <see cref="InputRequiredResult"/> and stores the continuation for future retries.
/// If the handler throws <see cref="InputRequiredException"/>, the result is returned directly
/// without storing a continuation (explicit MRTR path).
/// </summary>
private async Task<JsonNode?> AwaitMrtrHandlerAsync(
Task<JsonNode?> handlerTask,
MrtrContinuation continuation,
Task<MrtrExchange> exchangeTask,
CancellationToken cancellationToken)
{
// Link the current request's cancellation to the handler's long-lived CTS.
// On the initial call this is redundant (handlerCts is already linked to cancellationToken)
// but on retries this is critical: the retry's combinedCts cancellation must flow to the handler.
// This is how notifications/cancelled for the retry's request ID reaches the handler.
using var registration = cancellationToken.Register(
static state => ((MrtrContinuation)state!).CancelHandler(), continuation);
// Race handler against MRTR exchange.
var completedTask = await Task.WhenAny(handlerTask, exchangeTask).ConfigureAwait(false);
if (completedTask == handlerTask)
{
// Handler completed - return its result, propagate its exception, or handle InputRequiredException.
return await AwaitHandlerWithInputRequiredResultHandlingAsync(handlerTask).ConfigureAwait(false);
}
// Exchange arrived - handler needs input from the client (implicit MRTR path).
var exchange = await exchangeTask.ConfigureAwait(false);
var correlationId = Guid.NewGuid().ToString("N");
var inputRequiredResult = new InputRequiredResult
{
InputRequests = new Dictionary<string, InputRequest> { [exchange.Key] = exchange.InputRequest },
RequestState = correlationId,
};
// Store the continuation so the retry can resume the handler.
continuation.PendingExchange = exchange;
_mrtrContinuations[correlationId] = continuation;
return SerializeInputRequiredResult(inputRequiredResult);
}
/// <summary>
/// Fire-and-forget observer for an MRTR handler task. Logs unhandled exceptions at Debug
/// level (the same exception still propagates to the request pipeline, so Debug avoids
/// double-reporting at Error) and decrements <see cref="_mrtrInFlightCount"/> when the
/// handler completes, following the same in-flight tracking pattern as <see cref="McpSessionHandler"/>.
/// </summary>
private async Task ObserveHandlerCompletionAsync(Task<JsonNode?> handlerTask)
{
try
{
await handlerTask.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Handler cancelled - expected lifecycle event (disposal, client cancel, session shutdown).
}
catch (InputRequiredException)
{
// Explicit MRTR: handler explicitly signaling an InputRequiredResult. Not an error.
}
catch (Exception ex)
{
MrtrHandlerError(ex);
}
finally
{
if (Interlocked.Decrement(ref _mrtrInFlightCount) == 0)
{
_allMrtrHandlersCompleted.TrySetResult(true);
}
}
}
/// <summary>
/// Awaits a handler task, catching <see cref="InputRequiredException"/> to convert it to an
/// <see cref="InputRequiredResult"/> JSON response without storing a continuation.
/// </summary>
private static async Task<JsonNode?> AwaitHandlerWithInputRequiredResultHandlingAsync(Task<JsonNode?> handlerTask)
{
try
{
return await handlerTask.ConfigureAwait(false);
}
catch (InputRequiredException ex)
{
return SerializeInputRequiredResult(ex.Result);
}
}
[LoggerMessage(Level = LogLevel.Error, Message = "\"{ToolName}\" threw an unhandled exception.")]
private partial void ToolCallError(string toolName, Exception exception);
@ -1124,6 +1612,12 @@ internal sealed partial class McpServerImpl : McpServer
[LoggerMessage(Level = LogLevel.Information, Message = "ReadResource \"{ResourceUri}\" completed.")]
private partial void ReadResourceCompleted(string resourceUri);
[LoggerMessage(Level = LogLevel.Debug, Message = "Cancelled {Count} pending MRTR continuation(s) during session disposal.")]
private partial void MrtrContinuationsCancelled(int count);
[LoggerMessage(Level = LogLevel.Debug, Message = "An MRTR handler threw an unhandled exception.")]
private partial void MrtrHandlerError(Exception exception);
/// <summary>
/// Executes a tool call as a task and returns a CallToolTaskResult immediately.
/// </summary>

View file

@ -0,0 +1,78 @@
using ModelContextProtocol.Protocol;
namespace ModelContextProtocol.Server;
/// <summary>
/// Manages the MRTR (Multi Round-Trip Request) coordination between a handler and the pipeline.
/// When a handler calls <see cref="McpServer.ElicitAsync(ModelContextProtocol.Protocol.ElicitRequestParams, System.Threading.CancellationToken)"/> or
/// <see cref="McpServer.SampleAsync(ModelContextProtocol.Protocol.CreateMessageRequestParams, System.Threading.CancellationToken)"/>,
/// the handler sets the exchange TCS and suspends on a response TCS. The pipeline detects the exchange
/// via <see cref="InitialExchangeTask"/> or the task returned by <see cref="ResetForNextExchange"/>,
/// sends an <see cref="InputRequiredResult"/>, and later completes the response TCS when the retry arrives.
/// </summary>
internal sealed class MrtrContext
{
private TaskCompletionSource<MrtrExchange> _exchangeTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
private int _nextInputRequestId;
/// <summary>
/// Gets the task for the initial MRTR exchange. Set once in the constructor and never changes.
/// For subsequent exchanges after a retry, use the task returned by <see cref="ResetForNextExchange"/>.
/// </summary>
public Task<MrtrExchange> InitialExchangeTask { get; }
public MrtrContext()
{
InitialExchangeTask = _exchangeTcs.Task;
}
/// <summary>
/// Prepares the context for the next round of exchange after a retry arrives.
/// Uses <see cref="Interlocked.CompareExchange{T}"/> to atomically validate that
/// <see cref="_exchangeTcs"/> still references the TCS that produced <paramref name="previousExchange"/>,
/// ensuring concurrent calls reliably fail.
/// </summary>
/// <param name="previousExchange">The exchange from the previous round whose
/// response has been (or is about to be) completed.</param>
/// <returns>A task that completes when the handler requests input via
/// <see cref="RequestInputAsync"/>.</returns>
/// <exception cref="InvalidOperationException">The context state was modified concurrently.</exception>
public Task<MrtrExchange> ResetForNextExchange(MrtrExchange previousExchange)
{
var newTcs = new TaskCompletionSource<MrtrExchange>(TaskCreationOptions.RunContinuationsAsynchronously);
if (Interlocked.CompareExchange(ref _exchangeTcs, newTcs, previousExchange.SourceTcs) != previousExchange.SourceTcs)
{
throw new InvalidOperationException("MrtrContext was modified concurrently.");
}
return newTcs.Task;
}
/// <summary>
/// Called by <see cref="McpServer.ElicitAsync(ModelContextProtocol.Protocol.ElicitRequestParams, System.Threading.CancellationToken)"/>
/// or <see cref="McpServer.SampleAsync(ModelContextProtocol.Protocol.CreateMessageRequestParams, System.Threading.CancellationToken)"/>
/// to request input from the client via the MRTR mechanism.
/// </summary>
/// <param name="inputRequest">The input request describing what the server needs.</param>
/// <param name="cancellationToken">A token to cancel the wait for input.</param>
/// <returns>The client's response to the input request.</returns>
/// <exception cref="InvalidOperationException">A concurrent server-to-client request is already pending.</exception>
public async Task<InputResponse> RequestInputAsync(InputRequest inputRequest, CancellationToken cancellationToken)
{
var key = $"input_{Interlocked.Increment(ref _nextInputRequestId)}";
var tcs = _exchangeTcs;
var exchange = new MrtrExchange(key, inputRequest, tcs);
// TrySetResult is the sole atomicity gate. If it returns false,
// the TCS was already completed by a prior call - concurrent exchanges
// are not supported.
if (!tcs.TrySetResult(exchange))
{
throw new InvalidOperationException(
"Concurrent server-to-client requests are not supported. " +
"Await each ElicitAsync, SampleAsync, or RequestRootsAsync call before making another.");
}
return await exchange.ResponseTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
}
}

View file

@ -0,0 +1,50 @@
using System.Text.Json.Nodes;
namespace ModelContextProtocol.Server;
/// <summary>
/// Represents the lifecycle state for an MRTR handler invocation across retries.
/// Created when the handler starts and stored in <c>_mrtrContinuations</c> when
/// the handler suspends waiting for client input.
/// </summary>
internal sealed class MrtrContinuation
{
private readonly CancellationTokenSource _handlerCts;
public MrtrContinuation(CancellationTokenSource handlerCts, Task<JsonNode?> handlerTask, MrtrContext mrtrContext)
{
_handlerCts = handlerCts;
HandlerTask = handlerTask;
MrtrContext = mrtrContext;
}
/// <summary>
/// Gets a token that cancels when the handler should be aborted.
/// Passed to the handler at creation and remains valid across retries.
/// </summary>
public CancellationToken HandlerToken => _handlerCts.Token;
/// <summary>
/// The handler task that is suspended awaiting input.
/// </summary>
public Task<JsonNode?> HandlerTask { get; }
/// <summary>
/// The MRTR context for the handler's async flow.
/// </summary>
public MrtrContext MrtrContext { get; }
/// <summary>
/// The exchange that is awaiting a response from the client.
/// Set each time the handler suspends on a new exchange.
/// </summary>
public MrtrExchange? PendingExchange { get; set; }
/// <summary>
/// Cancels the handler. Safe to call multiple times and concurrently -
/// <see cref="CancellationTokenSource.Cancel()"/> is thread-safe with itself.
/// The CTS is intentionally never disposed to avoid deadlock risks from
/// calling Cancel/Dispose inside synchronization primitives.
/// </summary>
public void CancelHandler() => _handlerCts.Cancel();
}

View file

@ -0,0 +1,41 @@
using ModelContextProtocol.Protocol;
namespace ModelContextProtocol.Server;
/// <summary>
/// Represents a single exchange between the handler and the pipeline during an MRTR flow.
/// The handler creates the exchange and awaits the response TCS. The pipeline reads the exchange,
/// sends the <see cref="InputRequest"/> to the client, and completes the TCS when the response arrives.
/// </summary>
internal sealed class MrtrExchange
{
public MrtrExchange(string key, InputRequest inputRequest, TaskCompletionSource<MrtrExchange> sourceTcs)
{
Key = key;
InputRequest = inputRequest;
SourceTcs = sourceTcs;
ResponseTcs = new TaskCompletionSource<InputResponse>(TaskCreationOptions.RunContinuationsAsynchronously);
}
/// <summary>
/// The unique key identifying this exchange within the MRTR round trip.
/// </summary>
public string Key { get; }
/// <summary>
/// The input request that needs to be fulfilled by the client.
/// </summary>
public InputRequest InputRequest { get; }
/// <summary>
/// The <see cref="TaskCompletionSource{TResult}"/> that this exchange was set as the result of.
/// Used by <see cref="MrtrContext.ResetForNextExchange"/> on retry to validate
/// the expected state via <see cref="Interlocked.CompareExchange{T}"/>.
/// </summary>
internal TaskCompletionSource<MrtrExchange> SourceTcs { get; }
/// <summary>
/// The TCS that will be completed with the client's response.
/// </summary>
public TaskCompletionSource<InputResponse> ResponseTcs { get; }
}