Skip to content

Multi Round-Trip Requests (MRTR)#1458

Draft
halter73 wants to merge 5 commits into
mainfrom
halter73/mrtr
Draft

Multi Round-Trip Requests (MRTR)#1458
halter73 wants to merge 5 commits into
mainfrom
halter73/mrtr

Conversation

@halter73
Copy link
Copy Markdown
Contributor

@halter73 halter73 commented Mar 21, 2026

Spec PR: modelcontextprotocol/modelcontextprotocol#2322
Status: Draft — proof-of-concept reference implementation for SEP-2322

Summary

This PR implements Multi Round-Trip Requests (MRTR) in the C# MCP SDK, demonstrating that the SEP-2322 proposal can be implemented in a fully backwards-compatible way. The existing await-based server APIs (ElicitAsync, SampleAsync, RequestRootsAsync) continue to work identically — the SDK transparently handles the new wire protocol when both sides opt in, and falls back to legacy JSON-RPC requests when they don't.

This implementation is intended to serve as a reference for other SDK maintainers (TypeScript, Python, Go, Java) implementing MRTR, particularly around the backwards compatibility story and the interplay between protocol negotiation and handler behavior.

35 files changed, 4,725 lines added across 10 commits. ~2,200 lines of test coverage.

Motivation

As discussed in the Core Maintainer's meeting (accepted with changes, 🟢1 🟡7 🔴0), MRTR addresses fundamental scalability issues with the current server-to-client request model:

  • Stateless servers can't send requests: SSE/stdio-based server→client requests require an open stream, which stateless HTTP servers don't have
  • Load balancer incompatibility: Server-initiated requests over SSE can be routed to different server instances than the one that sent the request
  • Simplified transport requirements: Clients no longer need to support bidirectional messaging for elicitation/sampling — standard HTTP request/response is sufficient

What This PR Demonstrates

1. Full Backwards Compatibility via Protocol Negotiation

This was the most debated topic in the spec PR (felixweinberger, maciej-kisiel, CaitieM20). The C# SDK proves all four combinations work seamlessly:

Server Client Behavior
Experimental Experimental MRTR — incomplete results with retry cycle
Experimental Stable FallbackElicitAsync/SampleAsync automatically send legacy JSON-RPC requests, as do IncompleteResultExceptions
Stable Experimental Client accepts stable protocol; MRTR retry loop is a no-op
Stable Stable Standard behavior — no MRTR, no changes

Key insight: The existing await server.ElicitAsync(...) API doesn't change at all. When the connected client supports MRTR, the SDK returns an IncompleteResult with inputRequests instead of sending a elicitation/create JSON-RPC request. When the client doesn't support MRTR, it sends the legacy request. Tool authors don't need to know or care which path is taken.

The determination is made via protocol version negotiation during initialize:

// Server-side check (McpServerImpl.cs)
internal bool ClientSupportsMrtr() =>
    _negotiatedProtocolVersion is not null &&
    _negotiatedProtocolVersion == ServerOptions.ExperimentalProtocolVersion;

This directly answers Randgalt's question — yes, the server knows the client supports MRTR purely from the negotiated protocol version. No new capabilities are needed.

2. Type Discrimination via result_type

This was flagged as a critical issue by maxisbey: since Result allows arbitrary extra fields, an IncompleteResult with only optional fields is indistinguishable from a CallToolResult. The spec resolved this with a result_type discriminator field.

The C# SDK implements this cleanly:

  • Server side: IncompleteResult always serializes with "result_type": "incomplete"
  • Client side: McpClientImpl.SendRequestAsync() checks for result_type == "incomplete" on every response, triggers the retry loop if found
  • Default: When result_type is absent or any other value, the result deserializes as the expected type (backwards compatible)

This approach is extensible for future result types (tasks, callbacks, streaming) as CaitieM20 noted.

3. Two Server-Side API Levels

High-Level API (Stateful Servers)

Tool handlers use await — the SDK suspends the handler in memory and resumes it when the client retries with responses:

[McpServerTool, Description("Confirms an action with the user")]
public static async Task<string> ConfirmAction(McpServer server, string action, CancellationToken ct)
{
    // This call transparently uses MRTR or legacy JSON-RPC depending on the client
    var result = await server.ElicitAsync(new ElicitRequestParams
    {
        Message = $"Proceed with {action}?",
        RequestedSchema = new() { /* ... */ }
    }, ct);

    return result.Action == "accept" ? "Done!" : "Cancelled.";
}

Internally, the handler task is suspended via MrtrContext and stored in a ConcurrentDictionary<string, MrtrContinuation> keyed by a generated continuation ID. On retry, the continuation is looked up, the handler is resumed with the client's response, and execution continues from where ElicitAsync was awaited.

Low-Level API (Stateless Servers)

For servers that can't keep handler state in memory (stateless HTTP, serverless functions), handlers throw IncompleteResultException and manage their own state via requestState:

[McpServerTool, Description("Stateless tool with elicitation")]
public static string StatelessTool(McpServer server, RequestContext<CallToolRequestParams> context)
{
    // On retry, process the client's response
    if (context.Params.InputResponses?.TryGetValue("user_input", out var response) is true)
    {
        return $"User said: {response.ElicitationResult?.Action}";
    }

    if (!server.IsMrtrSupported)
        return "MRTR not supported by this client.";

    throw new IncompleteResultException(
        inputRequests: new Dictionary<string, InputRequest>
        {
            ["user_input"] = InputRequest.ForElicitation(new ElicitRequestParams { /* ... */ })
        },
        requestState: "awaiting-input");
}

The IncompleteResultException approach was chosen because C# doesn't yet have discriminated unions. However, C# unions are in active development, and when available, IncompleteResult return types will be a natural fit — returning either the final result or an IncompleteResult from a single method without exceptions. The exception-based API will remain supported but we expect the union-based approach to be preferred.

4. Client-Side Transparency

The client retry loop is fully automatic. CallToolAsync looks the same regardless of whether MRTR is active:

var result = await client.CallToolAsync("ConfirmAction", new { action = "deploy" });

Under the hood, McpClientImpl.SendRequestAsync detects result_type: "incomplete", resolves all inputRequests by dispatching to the registered handlers (ElicitationHandler, SamplingHandler, RootsHandler), and retries with inputResponses attached. Multiple input requests in a single IncompleteResult are resolved concurrently — all handler tasks are started immediately, then awaited.

The retry loop has a maximum of 10 attempts (not currently user-configurable). The escape hatch is CancellationToken.

5. Concurrent Multi-Input Resolution

A single IncompleteResult can request multiple types of input simultaneously:

throw new IncompleteResultException(
    inputRequests: new Dictionary<string, InputRequest>
    {
        ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams { /* ... */ }),
        ["summarize"] = InputRequest.ForSampling(new CreateMessageRequestParams { /* ... */ }),
        ["roots"] = InputRequest.ForRootsList(new ListRootsRequestParams())
    },
    requestState: "multi-input");

The client resolves all three concurrently and retries with all responses in one request. This is verified by tests using TaskCompletionSource barriers that prove all three handlers run simultaneously.

6. No Old-Style Requests with MRTR

When MRTR is negotiated, the server never sends elicitation/create, sampling/createMessage, or roots/list JSON-RPC requests. This is verified by message filter tests that inspect every outgoing message and confirm only IncompleteResult responses are used.

This is important for clients that can't support SSE streams (cloud-hosted clients) — MRTR means they can support elicitation and sampling via standard HTTP request/response.

7. MRTR-Native Backward Compatibility for the Low-Level API

Tools written with the low-level IncompleteResultException pattern work automatically with clients that don't support MRTR. When a tool throws IncompleteResultException and the client hasn't negotiated MRTR, the SDK resolves each InputRequest by sending the corresponding standard JSON-RPC call (elicitation, sampling, or roots) to the client, then retries the handler with the resolved responses — all internal to the server. The client never sees the IncompleteResult.

This mirrors the Python SDK's sse_retry_shim approach, but is built into the SDK rather than requiring an explicit wrapper. It means authors can write a single tool implementation using the MRTR-native pattern and it works with any client:

[McpServerTool, Description("Get weather with user's preferred units")]
public static string GetWeather(RequestContext<CallToolRequestParams> context, string location)
{
    if (context.Params.InputResponses?.TryGetValue("units", out var response) == true)
    {
        var units = response.ElicitationResult?.Content?.FirstOrDefault().Value;
        return $"Weather for {location} in {units}: 72°";
    }

    throw new IncompleteResultException(
        inputRequests: new Dictionary<string, InputRequest>
        {
            ["units"] = InputRequest.ForElicitation(new ElicitRequestParams
            {
                Message = "Which temperature units?",
                RequestedSchema = new()
            })
        },
        requestState: "awaiting-units");
}
  • MRTR client: IncompleteResult sent over the wire → client resolves and retries
  • Non-MRTR client: SDK sends elicitation/create JSON-RPC to the client, collects the response, and retries the handler internally

The IsMrtrSupported check is no longer required for basic functionality — it remains useful for tools that want to provide a different experience when MRTR isn't available (e.g., a richer fallback message), but tools that just want elicitation/sampling can throw IncompleteResultException unconditionally.

New Protocol Types

Type Description
IncompleteResult Response with result_type: "incomplete", inputRequests, and/or requestState
IncompleteResultException Exception thrown by low-level handlers to return an IncompleteResult
InputRequest Server-to-client request wrapper with factory methods: ForElicitation(), ForSampling(), ForRootsList()
InputResponse Client response wrapper with typed accessors: ElicitationResult, SamplingResult, RootsResult

All new types are marked [Experimental(MCPEXP001)] and gated behind ExperimentalProtocolVersion = "2026-06-XX".

RequestParams Extensions

All request parameter types (CallToolRequestParams, GetPromptRequestParams, ReadResourceRequestParams, etc.) inherit two new optional properties from RequestParams:

  • InputResponses — Client's responses to the server's input requests, keyed by the same keys from inputRequests
  • RequestState — Opaque string echoed back from the previous IncompleteResult

These are populated only on retries. On initial requests, both are null.

Test Coverage

~2,200 lines of tests across 8 test files covering:

  • Protocol conformance: Full MRTR round-trip cycle, result_type discriminator, serialization/deserialization
  • High-level API: ElicitAsync/SampleAsync with MRTR, automatic fallback to legacy
  • Low-level API: IncompleteResultException, requestState management, multi-round trips
  • Backwards compatibility: All 4 combinations of experimental/stable client and server, plus MRTR-native tools with non-MRTR clients
  • Concurrent resolution: Multiple inputRequests in a single IncompleteResult, verified concurrent execution
  • Stateless mode: Full end-to-end tests with Streamable HTTP in stateless mode
  • Edge cases: Cancellation mid-retry, concurrent ElicitAsync/SampleAsync calls (prevented), message filter verification
  • Tasks integration: MRTR with task-augmented tool calls

Documentation

  • New: docs/concepts/mrtr/mrtr.md — comprehensive guide with high-level and low-level API examples, compatibility matrix, backward compatibility section
  • Updated: elicitation.md, sampling.md, roots.md — each now includes an MRTR section showing both await-based and IncompleteResultException-based approaches
  • Fixed: toc.yml and index.md navigation entries

Open Questions for the Spec

  1. InputRequest method field: The spec's InputRequest type is a union of CreateMessageRequest | ElicitRequest | ListRootsRequest. In practice, the method field is needed for deserialization since the params shapes can overlap. The C# SDK uses InputRequest.Method as the discriminator for typed deserialization. The spec should be explicit that method is required.
  2. I want to second the point @mikekistler brought up transport WG about the inability to smuggle more general JSON-RPC responses like errors back from the server to client with inputResponses. This could make the upgrade scenario for servers relying on errors from upgrading to the new protocol if they're capable of maintaining the stateful session required for such an interaction to makes sense even in stdio. While this isn't as critical as keeping sessions in the protocol in general, I strongly agree with this feedback.
  3. Can stateless servers get sampling/eliciation/roots capabilities via headers somehow in the next protocol version, so we don't degrade the experience during the transition from stateful to stateless either? I know there are other proposals for that, but I think that specifically should be packaged with the MRTR spec revision so SDKs can provide a coherent story about the upgrade to the new protocol.
  4. All new types are marked [Experimental(MCPEXP001)] and gated behind ExperimentalProtocolVersion = "2026-06-XX", so that Mcp-Protocol-Header version is what I'm doing for protocol negotiation, and naturally the initialize handshake in stateful modes. I think we have conformance tests these must match in stateful mode). Anyway, is that how we're going to do this before the next protocol version is ratified?

@halter73 halter73 changed the title Multi Round-Trip Requests (MRTR) — C# SDK Reference Implementation Multi Round-Trip Requests (MRTR) Mar 21, 2026
@halter73 halter73 force-pushed the halter73/mrtr branch 2 times, most recently from f1dd4c4 to 5845866 Compare March 21, 2026 17:19
@halter73 halter73 requested a review from stephentoub March 21, 2026 18:40
halter73 and others added 5 commits May 19, 2026 07:31
Resolves conflicts from rebasing the MRTR work (originally branched from
4140c6d) onto the current main (b8c4d95). Key conflict resolutions:

- McpClientImpl.SendRequestAsync: combine SEP-2243 tool-context attachment
  with MRTR retry loop for IncompleteResult.
- McpSessionHandler.SendRequestAsync: take MRTR's outgoing filter and
  request logging.
- McpServerImpl.InvokeHandlerAsync: take MRTR's CreateDestinationBoundServer.
- docs/concepts/index.md: combine main's Tasks entry with MRTR additions.
- MapMcpTests.cs: keep main's new IncomingFilter/OutgoingFilter tests in
  full, drop MRTR's outdated overload usage by going through configureClient.
- MrtrIntegrationTests.cs: gate with #if !NET472 (uses ReadLineAsync(CT)).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- IncompleteResult/IncompleteResultException -> InputRequiredResult/InputRequiredException
- Wire format: result_type -> resultType, `incomplete` -> `input_required`
- Drop ExperimentalProtocolVersion option; opt in via ProtocolVersion = `DRAFT-2026-v1`
- Add DraftProtocolVersion constant and include in SupportedProtocolVersions
- Restrict implicit MRTR continuation path to legacy stateful sessions; DRAFT-2026-v1
  and stateless sessions always use the exception-based path

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Implicit MRTR (handler suspension via ElicitAsync) requires both client
  support (DRAFT-2026-v1) and a stateful session. All other cases fall through
  to the exception-based path, which transparently resolves InputRequiredException
  via legacy JSON-RPC requests for clients that don't speak MRTR.
- Drop the now-redundant ProtocolVersion pin from ConfigureExperimentalServer in
  MapMcpTests.Mrtr; server uses the negotiated version like any other server.
- Rewrite the obsolete WithoutExperimental low-level test now that the experimental
  flag is gone; it now verifies retry exhaustion when no input requests are supplied.
- Update other test assertions to use the literal DRAFT-2026-v1 string.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@halter73 halter73 marked this pull request as ready for review May 26, 2026 14:53
@halter73 halter73 requested a review from tarekgh May 26, 2026 15:03
@halter73 halter73 marked this pull request as draft May 26, 2026 15:06
Comment on lines +50 to +67
public CreateMessageResult? SamplingResult =>
JsonSerializer.Deserialize(RawValue, McpJsonUtilities.JsonContext.Default.CreateMessageResult);

/// <summary>
/// Gets the response as an <see cref="ElicitResult"/>.
/// </summary>
/// <returns>The deserialized elicitation result, or <see langword="null"/> if deserialization fails.</returns>
[JsonIgnore]
public ElicitResult? ElicitationResult =>
JsonSerializer.Deserialize(RawValue, McpJsonUtilities.JsonContext.Default.ElicitResult);

/// <summary>
/// Gets the response as a <see cref="ListRootsResult"/>.
/// </summary>
/// <returns>The deserialized roots list result, or <see langword="null"/> if deserialization fails.</returns>
[JsonIgnore]
public ListRootsResult? RootsResult =>
JsonSerializer.Deserialize(RawValue, McpJsonUtilities.JsonContext.Default.ListRootsResult);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typed accessors: silent garbage on wrong call + reparse on every access

These three properties each call JsonSerializer.Deserialize(RawValue, ...) unconditionally on every read.

  1. No type guard. Unlike InputRequest, which gates on Method, InputResponse has no discriminator on the wire (per spec, the type is implied by the matching InputRequest.Method). Since none of ElicitResult/CreateMessageResult/ListRootsResult mark any property as required and STJ ignores unknown properties by default, reading the wrong accessor returns a fully-populated-but-empty object (e.g., ElicitResult { Action = "cancel", Content = null }) instead of throwing. Callers cannot distinguish a real "cancel" from picking the wrong accessor.
  2. Re-deserializes on every access. Properties look like cheap field reads, but each one parses the JsonElement and allocates a fresh result object. result.SamplingResult.Model followed by result.SamplingResult.Role is two full deserializations.

Suggestion: drop the three typed properties; keep only the generic helper

public T? Deserialize<T>(JsonTypeInfo<T> typeInfo)
{
    Throw.IfNull(typeInfo);
    return JsonSerializer.Deserialize(RawValue, typeInfo);
}

Call sites change from response.ElicitationResult?.Action to:

var elicit = response.Deserialize(McpJsonUtilities.JsonContext.Default.ElicitResult);
var action = elicit?.Action;

Why:

  • The caller already knows the method from the InputRequiredResult.InputRequests key, so the typed properties save nothing and add a footgun.
  • One explicit deserialization per caller (callers can cache locally), with no hidden per-access cost.
  • Smaller [Experimental] surface to evolve as SEP-2322 settles.
  • Consistent with the spec keeping the discriminator on the request side.

Keep FromSamplingResult / FromElicitResult / FromRootsResult factories. They still document expected types and stay symmetric with InputRequest.For*.

In-SDK consumers are tests and the example in InputRequiredException XML doc; both updates are mechanical.

Comment on lines +548 to +565
private async ValueTask<IDictionary<string, InputResponse>> ResolveInputRequestsAsync(
IDictionary<string, InputRequest> inputRequests,
CancellationToken cancellationToken)
{
var responses = new Dictionary<string, InputResponse>(inputRequests.Count);

// Resolve all input requests concurrently
var tasks = new List<(string Key, Task<InputResponse> Task)>(inputRequests.Count);
foreach (var kvp in inputRequests)
{
tasks.Add((kvp.Key, ResolveInputRequestAsync(kvp.Value, cancellationToken)));
}

foreach (var entry in tasks)
{
responses[entry.Key] = await entry.Task.ConfigureAwait(false);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failure-mode cleanup: remaining handlers leak on first failure

Tasks are started concurrently (good), then gathered sequentially. On the happy path this is equivalent to Task.WhenAll. On failure, the first await entry.Task rethrows and the method exits, but:

  1. Remaining handlers keep running. Sampling/elicitation/roots are user-facing (model calls, UI prompts); the caller has given up, yet dialogs stay open and tokens keep burning. There is no proactive cancellation of tasks[1..N].
  2. Late successes are dropped silently. Anything that completes after we bail is thrown away (handler ran for nothing).
  3. Late exceptions risk being unobserved. Task.WhenAll would aggregate them onto a single observed task; here each remaining task is observed only if someone awaits it, which nobody does.

The mirror loop in McpServerImpl.InvokeWithInputRequiredResultHandlingAsync (back-compat shim) is worse: it is fully sequential, so each elicitation/create / sampling/createMessage to the client waits for the previous one to return, multiplying round-trip latency.

Suggestion

Use Task.WhenAll with a linked CTS so a single failure stops the rest cleanly and all exceptions are observed:

private async ValueTask<IDictionary<string, InputResponse>> ResolveInputRequestsAsync(
    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(kvp.Value, linkedCts.Token));

    try
    {
        await Task.WhenAll(keyed.Select(k => k.Task)).ConfigureAwait(false);
    }
    catch
    {
        linkedCts.Cancel(); // stop any handlers still running
        try { await Task.WhenAll(keyed.Select(k => k.Task)).ConfigureAwait(false); } catch { }
        throw;
    }

    var responses = new Dictionary<string, InputResponse>(keyed.Length);
    foreach (var (key, task) in keyed)
        responses[key] = task.Result;
    return responses;
}

Apply the same shape to the server-side back-compat resolver in McpServerImpl so its requests start concurrently too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants