Skip to content

Multi Round-Trip Requests (MRTR)#1458

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

Multi Round-Trip Requests (MRTR)#1458
halter73 wants to merge 31 commits intomainfrom
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.

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
halter73 and others added 8 commits March 22, 2026 19:30
Harden the MRTR (Multi Round-Trip Request) implementation to correctly
handle cancellation across retries, clean shutdown, and handler lifecycle
tracking.

Thread-safety:
- Replace mutable ExchangeTask property with immutable InitialExchangeTask
  and return-value data flow from ResetForNextExchange
- Use Interlocked.CompareExchange in ResetForNextExchange to validate
  expected state, ensuring concurrent calls reliably fail
- Use TrySetResult as the sole atomicity gate in RequestInputAsync, with
  explicit failure on concurrent exchanges
- Store SourceTcs back-reference in MrtrExchange for CAS validation

Cancellation:
- Introduce a long-lived handler CTS (encapsulated in MrtrContinuation)
  that survives across retries, keeping the handler cancellable after the
  original request's combinedCts is disposed
- Bridge each retry's cancellation to the handler CTS via
  CancellationTokenRegistration in AwaitMrtrHandlerAsync
- Check TrySetResult/TrySetException return values on retry to detect
  already-cancelled exchanges
- CTS is never disposed (like Kestrel's HttpContext.RequestAborted) to
  avoid deadlock risks from Cancel/Dispose inside synchronization
  primitives. CancelHandler() is the sole operation and is thread-safe.

Shutdown:
- Dispose session handler before iterating _mrtrContinuations so no new
  continuations can be created during the cleanup loop
- Track MRTR handler tasks with inFlightCount + TCS drain pattern
  (matching McpSessionHandler.ProcessMessagesCoreAsync) so DisposeAsync
  waits for all handlers to complete before returning
- Add ObserveHandlerCompletionAsync fire-and-forget observer that logs
  unhandled handler exceptions at Error level

Logging:
- Exclude IncompleteResultException from Error-level ToolCallError logging
  since it is normal MRTR control flow, not an error

Simplifications:
- Flow MrtrContext via JsonRpcMessageContext property instead of
  _pendingMrtrContexts ConcurrentDictionary with synchronous-before-await
  assumptions
- MrtrContinuation is a lifecycle object created upfront, eliminating
  CTS disposal branching, orphanedCts tracking, and post-drain cleanup

Tests (8 new):
- ServerDisposal_CancelsHandlerCancellationToken_DuringMrtr
- CancellationNotification_DuringInFlightMrtrRetry_CancelsHandler
- CancellationNotification_ForExpiredRequestId_DoesNotAffectHandler
- DisposeAsync_WaitsForMrtrHandler_BeforeReturning
- HandlerException_DuringMrtr_IsLoggedAtErrorLevel
- IncompleteResultException_IsNotLoggedAtErrorLevel

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace non-generic TaskCompletionSource (introduced in .NET 5) with
TaskCompletionSource<bool> in McpClientMrtrTests.cs so the test project
compiles against net472, which only has TaskCompletionSource<TResult>.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add support for tools to perform ephemeral MRTR exchanges before
committing to a background task. This enables a two-phase workflow:

1. Ephemeral phase: The handler uses ElicitAsync/SampleAsync via MRTR
   to gather user input (e.g., confirmation before expensive operations).
2. Task phase: The handler calls CreateTaskAsync() to transition to a
   background task, receiving a task ID and cancellation token.

API surface:
- McpServerToolAttribute.DeferTaskCreation property
- McpServerToolCreateOptions.DeferTaskCreation property
- McpServerTool.DeferTaskCreation virtual property (overridden in
  AIFunctionMcpServerTool and DelegatingMcpServerTool)
- McpServer.CreateTaskAsync() virtual method (overridden in
  DestinationBoundMcpServer)

Implementation:
- DeferredTaskInfo carries task metadata across MRTR continuations,
  with signal/ack TCS pair for handler ↔ framework coordination.
- ConfigureTools attaches DeferredTaskInfo to MrtrContext when
  DeferTaskCreation is enabled and client provides task metadata.
- AwaitMrtrHandlerAsync races handler vs exchange vs task creation
  signal (3-way WhenAny).
- HandleDeferredTaskCreationAsync creates the task, re-links the
  handler CTS to the task cancellation token, and acknowledges the
  handler so it can continue as a background task.
- TrackDeferredHandlerTaskAsync tracks completion and stores results
  (handler already tracked by ObserveHandlerCompletionAsync for
  in-flight counting).

If the handler returns without calling CreateTaskAsync(), a normal
(non-task) result is returned to the client.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…w, fix logging

Document DeferTaskCreation and CreateTaskAsync in MRTR and Tasks
conceptual docs with cross-references and matching test coverage.

Revert MrtrContext flow from JsonRpcMessageContext property back to
_mrtrContextsByRequestId ConcurrentDictionary with try/finally.

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

Add test verifying a tool can use the high-level MRTR elicit API then
throw IncompleteResultException to drop to the low-level API in a
single call. Replace try/finally with 'using var' for the
CancellationTokenRegistration. Fix task/mrtr comment and use modern
indexing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…acy JSON-RPC

When a tool throws IncompleteResultException and the client doesn't
support MRTR, the server now resolves each InputRequest by sending the
corresponding standard JSON-RPC call (elicitation, sampling, roots) to
the client and retries the handler with the responses. This allows
authors to write a single MRTR-native tool implementation that works
with any client.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The backcompat layer now returns 'without input requests' instead of
'Multi Round-Trip Requests' when IncompleteResultException has no
inputRequests.

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

Backcompat tests (McpClientMrtrCompatTests):
- 10-retry limit enforcement (tool that never completes)
- Empty inputRequests dictionary triggers immediate error
- Error propagation when client handler throws during resolve

Experimental mode test (McpClientMrtrTests):
- Client handler throws during MRTR input resolution: exception
  surfaces to caller, server logs cancelled MRTR continuation on
  disposal. This exercises a fundamental MRTR limitation where the
  client has no channel to communicate input resolution failures
  back to the server.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
halter73 and others added 7 commits March 23, 2026 15:31
IsMrtrSupported now returns true whenever the low-level MRTR API
(IncompleteResultException) can be used, not just when the client
natively negotiated MRTR. The only case where it returns false is
stateless mode with a non-MRTR client, where nobody can drive the
retry loop.

- Add IsLowLevelMrtrAvailable() to McpServerImpl
- Update DestinationBoundMcpServer.IsMrtrSupported to use it
- Error explicitly for stateless + non-MRTR in the IncompleteResult
  handler instead of silently serializing an unusable response
- Add Stateless_IncompleteResultException_WithoutMrtrClient_ReturnsError
  test verifying the error path
- Fix existing tests: stateless tests now properly negotiate MRTR,
  protocol test verifies IsMrtrSupported=true via backcompat

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Move server-behavior tests from Client/ to Server/ and drop the
misleading McpClient prefix. Most MRTR tests verify server-side
behavior (handler lifecycle, backcompat resolution, low-level API)
but were in Client/ because they use ClientServerTestBase.

Split McpClientMrtrTests (17 tests) into:
- Client/MrtrIntegrationTests: E2E round-trips and client retry (10)
- Server/MrtrHandlerLifecycleTests: cancellation, disposal, logging (7)

Rename and move to Server/:
- McpClientMrtrCompatTests → MrtrBackcompatTests
- McpClientMrtrLowLevelTests → MrtrLowLevelApiTests
- McpClientMrtrMessageFilterTests → MrtrMessageFilterTests
- McpClientMrtrSessionLimitTests → MrtrSessionLimitTests
- McpClientMrtrWithTasksTests → MrtrTaskIntegrationTests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add McpClient + Stateful Streamable HTTP integration tests for MRTR,
and a test verifying experimental protocol version negotiation. Fix the
sampling log assertion in MapMcpTests to handle MRTR mode where sampling
is embedded in the IncompleteResult exchange. Demote MrtrHandlerError
from Error to Debug to avoid double-logging since the observer just
confirms the exception was observed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add a shared ServerMessageTracker utility that records outgoing server-to-
client JSON-RPC request methods via an outgoing message filter. Wire it into
all 8 MRTR test classes and call AssertNoLegacyMrtrRequests() in ~35
experimental-mode tests to verify no legacy elicitation/create or
sampling/createMessage requests are sent.

Simplify MrtrMessageFilterTests by replacing its manual ConcurrentBag-based
tracking with the shared tracker (-29 lines).

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

Fix McpSessionHandler.SendRequestAsync to route through the outgoing message
filter pipeline instead of calling SendToRelatedTransportAsync directly. This
makes server-originated JSON-RPC requests (elicitation/create,
sampling/createMessage, roots/list) visible to outgoing filters, matching the
documented behavior that filters see all outgoing messages.

Enhance ServerMessageTracker to use both incoming and outgoing filters for
comprehensive MRTR protocol mode verification:
- Outgoing: detects IncompleteResult responses (result_type=incomplete) and
  legacy JSON-RPC requests
- Incoming: detects MRTR retries (requests with inputResponses or requestState)

Add AssertMrtrUsed() to ~35 experimental MRTR tests and AssertMrtrNotUsed()
to ~15 backward-compatibility tests, ensuring every MRTR test verifies the
correct protocol mode was used.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Refactor MRTR tests to provide extensive coverage with less code, making
it easy for reviewers and other SDK implementers to see edge case
coverage at a glance.

Test consolidation:
- Move MRTR tests into MapMcpTests.Mrtr.cs partial class, which runs
  every test across StreamableHttp, SSE, and Stateless transports
- Use Theory tests with (experimentalServer, experimentalClient) bools
  to cover all 4 MRTR/backcompat protocol combinations
- Delete MrtrBackcompatTests.cs, StatelessMrtrTests.cs, and
  StreamableHttpMrtrTests.cs (coverage subsumed by MapMcpTests)
- Reduce MrtrLowLevelApiTests.cs and MrtrProtocolTests.cs to unique
  protocol-level tests not covered by McpClient-based tests
- Update MrtrIntegrationTests doc to reflect edge-case focus and add
  simple happy-path smoke test for reviewer reference

Test improvements:
- Add ServerMessageTracker with AssertMrtrUsed()/AssertMrtrNotUsed()
  to verify correct protocol mode in every test
- Add NegotiatedProtocolVersion assertions to verify negotiation
- Add result.IsError checks on all success-path tool results
- Add ErrorCode assertions on all error-path tests
- Tighten all version assertions to exact values (no NotEqual)

Helpers:
- ConnectAsync takes Action<McpClientOptions>? to prevent bypassing
  transport config (path/TransportMode defaults)
- ConfigureExperimentalServer/ConfigureDefaultServer with distinct
  Implementation names for clear test output
- ConfigureMrtrHandlers configures elicitation, sampling, and roots
  handlers on an existing McpClientOptions object
- ConnectExperimentalAsync/ConnectDefaultAsync for common patterns
- Move EnablePollingAsync stateless test to MapMcpStatelessTests
- Add client-side warnings for legacy requests on MRTR sessions and
  IncompleteResult on non-MRTR sessions

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

When the caller's CancellationToken triggers an OperationCanceledException
during a pipe write, re-throw it instead of wrapping it in IOException.
This ensures callers get the expected OperationCanceledException when they
cancel their own token.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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