Conversation
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>
9 tasks
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>
…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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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:
ElicitAsync/SampleAsyncautomatically send legacy JSON-RPC requestsKey insight: The existing
await server.ElicitAsync(...)API doesn't change at all. When the connected client supports MRTR, the SDK returns anIncompleteResultwithinputRequestsinstead of sending aelicitation/createJSON-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: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_typeThis was flagged as a critical issue by maxisbey: since
Resultallows arbitrary extra fields, anIncompleteResultwith only optional fields is indistinguishable from aCallToolResult. The spec resolved this with aresult_typediscriminator field.The C# SDK implements this cleanly:
IncompleteResultalways serializes with"result_type": "incomplete"McpClientImpl.SendRequestAsync()checks forresult_type == "incomplete"on every response, triggers the retry loop if foundresult_typeis 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:Internally, the handler task is suspended via
MrtrContextand stored in aConcurrentDictionary<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 whereElicitAsyncwas awaited.Low-Level API (Stateless Servers)
For servers that can't keep handler state in memory (stateless HTTP, serverless functions), handlers throw
IncompleteResultExceptionand manage their own state viarequestState:The
IncompleteResultExceptionapproach was chosen because C# doesn't yet have discriminated unions. However, C# unions are in active development, and when available,IncompleteResultreturn types will be a natural fit — returning either the final result or anIncompleteResultfrom 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.
CallToolAsynclooks the same regardless of whether MRTR is active:Under the hood,
McpClientImpl.SendRequestAsyncdetectsresult_type: "incomplete", resolves allinputRequestsby dispatching to the registered handlers (ElicitationHandler,SamplingHandler,RootsHandler), and retries withinputResponsesattached. Multiple input requests in a singleIncompleteResultare 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
IncompleteResultcan request multiple types of input simultaneously:The client resolves all three concurrently and retries with all responses in one request. This is verified by tests using
TaskCompletionSourcebarriers 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, orroots/listJSON-RPC requests. This is verified by message filter tests that inspect every outgoing message and confirm onlyIncompleteResultresponses 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
IncompleteResultresult_type: "incomplete",inputRequests, and/orrequestStateIncompleteResultExceptionIncompleteResultInputRequestForElicitation(),ForSampling(),ForRootsList()InputResponseElicitationResult,SamplingResult,RootsResultAll new types are marked
[Experimental(MCPEXP001)]and gated behindExperimentalProtocolVersion = "2026-06-XX".RequestParamsExtensionsAll request parameter types (
CallToolRequestParams,GetPromptRequestParams,ReadResourceRequestParams, etc.) inherit two new optional properties fromRequestParams:InputResponses— Client's responses to the server's input requests, keyed by the same keys frominputRequestsRequestState— Opaque string echoed back from the previousIncompleteResultThese are populated only on retries. On initial requests, both are null.
Test Coverage
~2,200 lines of tests across 8 test files covering:
result_typediscriminator, serialization/deserializationElicitAsync/SampleAsyncwith MRTR, automatic fallback to legacyIncompleteResultException,requestStatemanagement, multi-round tripsinputRequestsin a singleIncompleteResult, verified concurrent executionElicitAsync/SampleAsynccalls (prevented), message filter verificationDocumentation
docs/concepts/mrtr/mrtr.md— comprehensive guide with high-level and low-level API examples, compatibility matrixelicitation.md,sampling.md,roots.md— each now includes an MRTR section showing bothawait-based andIncompleteResultException-based approachestoc.ymlandindex.mdnavigation entriesOpen Questions for the Spec
InputRequestmethod field: The spec'sInputRequesttype is a union ofCreateMessageRequest | ElicitRequest | ListRootsRequest. In practice, themethodfield is needed for deserialization since the params shapes can overlap. The C# SDK usesInputRequest.Methodas the discriminator for typed deserialization. The spec should be explicit thatmethodis required.