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
7 changed files with 5832 additions and 103 deletions

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; }
}