Skip to content

Multi Round-Trip Requests (MRTR)#1458

Draft
halter73 wants to merge 16 commits intomainfrom
halter73/mrtr
Draft

Multi Round-Trip Requests (MRTR)#1458
halter73 wants to merge 16 commits intomainfrom
halter73/mrtr

Conversation

@halter73
Copy link
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
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.

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
  • 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
  • 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.

halter73 and others added 12 commits March 20, 2026 11:32
Move MRTR logic out of McpServer and McpClient base classes into their
internal implementations, keeping the mockable API surface clean.

Server side:
- Remove McpServer.ActiveMrtrContext (was internal)
- Add MRTR interception to DestinationBoundMcpServer.SendRequestAsync
  with task guard (SampleAsTaskAsync/ElicitAsTaskAsync bypass MRTR)
- Remove MRTR branches from SampleAsync, ElicitAsync, RequestRootsCoreAsync
- Task status tracking (InputRequired) now works during MRTR

Client side:
- Remove McpClient.ResolveInputRequestsAsync (was internal abstract)
- Move MRTR retry loop into McpClientImpl.SendRequestAsync override
- Replace SendRequestWithMrtrAsync with existing McpSession typed helper
- Make resolve methods private on McpClientImpl

Add 4 new tests for MRTR+Tasks interaction:
- Task-augmented tool call with MRTR sampling
- MRTR elicitation through tool call
- SampleAsTaskAsync bypasses MRTR interception
- MRTR tool call and task-based sampling coexist

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Gate MRTR on a draft protocol version ("2026-06-XX") instead of the
experimental["mrtr"] capability. This matches how the real protocol will
work when MRTR is ratified — the protocol version IS the signal.

Changes:
- Add ExperimentalProtocolVersion property to McpClientOptions and
  McpServerOptions, marked [Experimental(MCPEXP001)]
- Add ExperimentalProtocolVersion constant to McpSessionHandler
- Client: request experimental version when option is set; accept it
  in server response validation
- Server: accept experimental version from client when option matches;
  ClientSupportsMrtr() checks negotiated version instead of capability
- StreamableHttpHandler: accept experimental version in header validation
- Remove experimental["mrtr"] capability advertisement and
  MrtrContext.ExperimentalCapabilityKey

Compatibility matrix (no failures):
- Both experimental: MRTR via IncompleteResult + retry
- Server exp, client not: Legacy JSON-RPC requests
- Client exp, server not: Negotiates to stable, retry loop is no-op
- Neither: Standard behavior

Tests:
- Update all existing MRTR tests to set ExperimentalProtocolVersion
- Add 5 new compatibility tests covering all matrix combinations
- All 1886 core + 324 AspNetCore tests pass on net10.0 and net9.0

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add IncompleteResultException for tool handlers to return incomplete
  results with inputRequests and/or requestState directly
- Add McpServer.IsMrtrSupported property for checking client compatibility
- Handle IncompleteResultException in MRTR wrapper and race handler
- Validate MRTR support when exception is thrown (returns JSON-RPC error
  if client doesn't support MRTR)
- Fall through to MRTR-aware invocation for unmatched requestState retries
- Add 8 protocol conformance tests (raw HTTP) for low-level MRTR flows
- Add 7 integration tests for client auto-retry of low-level tools
- Add MRTR concept documentation covering both high-level and low-level APIs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ith filters

- Test concurrent ElicitAsync+SampleAsync throws InvalidOperationException
  (MrtrContext prevents concurrent server-to-client requests)
- Test cancellation mid-retry stops the MRTR loop with OperationCanceledException
- Test via outgoing message filters that no old-style sampling/elicitation
  JSON-RPC requests are sent when MRTR is active
- Test that transport middleware sees IncompleteResult round-trips

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Allow IncompleteResultException to serialize as IncompleteResult in
stateless mode where ClientSupportsMrtr() returns false. The low-level
API is designed for stateless servers that cannot determine client MRTR
support.

Add 5 end-to-end tests using Streamable HTTP in stateless mode:
- Elicitation, sampling, and roots individually
- All three concurrent (with TCS concurrency proof barriers)
- Multi-round-trip with requestState across 2 retries

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add high-level and low-level MRTR examples to each feature doc:
- elicitation.md: ElicitAsync (transparent) + IncompleteResultException
- sampling.md: SampleAsync (transparent) + IncompleteResultException
- roots.md: RequestRootsAsync (transparent) + IncompleteResultException

Fix missing entries in docs navigation:
- toc.yml: Add Sampling under Client Features
- index.md: Add Tasks and MRTR to Base Protocol table

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Move MrtrExchange and MrtrContinuation into their own files to follow
the convention of one top-level class per file.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Proves that outgoing/incoming message filters can track and enforce
per-session MRTR flow limits using context.Server.SessionId:

- OutgoingFilter_TracksIncompleteResultsPerSession: verifies count
  increments on IncompleteResult and decrements after retry
- OutgoingFilter_CanEnforcePerSessionMrtrLimit: verifies replacing
  IncompleteResult with a JSON-RPC error when limit is exceeded

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@halter73 halter73 changed the title Multi Round-Trip Requests (MRTR) — C# SDK Reference Implementation Multi Round-Trip Requests (MRTR) Mar 21, 2026
…ion header

ClientSupportsMrtr now purely reflects whether the client negotiated
the MRTR protocol version, independent of server transport mode.
The stateless guard is moved to the call site that gates the high-level
await path (which requires storing continuations).

In stateless mode, each request creates a new McpServerImpl that never
sees the initialize handshake. The Mcp-Protocol-Version header is now
flowed via JsonRpcMessageContext.ProtocolVersion so the MRTR wrapper
can populate _negotiatedProtocolVersion, making IsMrtrSupported return
true when the client sends the experimental protocol version header.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
halter73 and others added 2 commits March 21, 2026 10:31
Tests that mirror the exact code patterns from mrtr.md and
elicitation.md docs in stateless mode:

- IsMrtrSupported returns false when client doesn't opt in
- IsMrtrSupported check + IncompleteResultException throw (the doc
  pattern) works end-to-end including ElicitResult.Content access
- Same pattern returns fallback when client doesn't opt in
- Load shedding (requestState-only) with IsMrtrSupported guard

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add SessionDelete_CancelsPendingMrtrContinuation test verifying:
  - MRTR continuation is cancelled on session DELETE
  - Debug-level log emitted for cancelled continuations
  - No Error-level log noise from handler cancellation
- Add SessionDelete_RetryAfterDelete_ReturnsSessionNotFound test
  verifying retry with stale requestState returns 404
- Add MrtrContinuationsCancelled debug log in DisposeAsync
- Skip ToolCallError log for OperationCanceledException during
  disposal (not a tool bug, just session teardown)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@halter73 halter73 requested a review from stephentoub March 21, 2026 18:40
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.

1 participant