Skip to content

Multi Round-Trip Requests (MRTR)#1458

Merged
halter73 merged 20 commits into
mainfrom
halter73/mrtr
Jun 3, 2026
Merged

Multi Round-Trip Requests (MRTR)#1458
halter73 merged 20 commits into
mainfrom
halter73/mrtr

Conversation

@halter73
Copy link
Copy Markdown
Contributor

@halter73 halter73 commented Mar 21, 2026

Summary

Implements SEP-2322: Multi Round-Trip Requests (MRTR) for the C# SDK.

MRTR lets a server tool ask the client for input — elicitation, sampling, or roots — as part of a single tool call by returning an incomplete result instead of a final one. The client resolves the input requests and retries the original tools/call with inputResponses attached, until the tool returns a final result.

This PR follows the ratified draft wire format and gates the new behavior on the negotiated protocol revision DRAFT-2026-v1. There are no experimental opt-in flags.

The API

InputRequiredException is the only way to do MRTR. A tool throws it with an InputRequiredResult containing inputRequests and/or requestState, and the SDK turns that into the right wire response for the negotiated protocol.

[McpServerTool]
public static string Ask(McpServer server, RequestContext<CallToolRequestParams> context, string question)
{
    if (context.Params!.InputResponses?["answer"].ElicitationResult is { } answered)
        return $"You said: {answered.Content?.FirstOrDefault().Value}";

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

    throw new InputRequiredException(
        inputRequests: new Dictionary<string, InputRequest>
        {
            ["answer"] = InputRequest.ForElicitation(new ElicitRequestParams { Message = question, RequestedSchema = new() }),
        },
        requestState: "awaiting");
}

McpServer.IsMrtrSupported returns true whenever the SDK can satisfy InputRequiredException — either natively (draft) or via the legacy resolver (current+stateful).

Compatibility matrix

Negotiated protocol Session Behavior
DRAFT-2026-v1 Stateful / Stateless Native — InputRequiredResult is serialized straight to the wire.
Current (2025-06-18 and earlier) Stateful Backcompat resolver — SDK sends standard elicitation/create / sampling/createMessage / roots/list requests, collects responses, retries the handler with inputResponses. Capped at 10 rounds.
Current Stateless Not supportedInputRequiredException raises an McpException.

Breaking changes under DRAFT-2026-v1

The draft revision removes the server-to-client elicitation/create, sampling/createMessage, and roots/list request methods. The SDK fails fast:

  • McpServer.ElicitAsync, SampleAsync, RequestRootsAsync, AsSamplingChatClient, ElicitAsTaskAsync, SampleAsTaskAsync all throw InvalidOperationException after a DRAFT-2026-v1 session is negotiated. The exception message points to the InputRequest.ForElicitation / ForSampling / ForRootsList replacement.
  • These methods continue to work normally under the current protocol revision and remain the recommended way to do simple one-shot client interactions.

Removed

  • The implicit MRTR machinery that intercepted high-level ElicitAsync/SampleAsync calls and suspended the handler across MRTR rounds. (Replaced by the explicit InputRequiredException contract.)
  • DeferTaskCreation on [McpServerTool] / McpServerToolCreateOptions and the server-side CreateTaskAsync API tied to it. Long-running tasks still use IMcpTaskStore as before.
  • The ExperimentalProtocolVersion opt-in — replaced by negotiating DRAFT-2026-v1 directly.

Follow-ups (intentionally left out of this PR)

  • The next protocol revision removes Mcp-Session-Id and the Stateful mode. When that lands, the current-protocol-stateful row of the matrix collapses into the stateless row, and the legacy elicitation/create / sampling/createMessage / roots/list resolver path can be deleted. The code has // TODO(stateless-draft): markers where that simplification will go.
  • Conformance scenarios for SEP-2322 are wired up in tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests and gated on NodeHelpers.HasMrtrScenarios(). Once conformance#188 merges, the gate can be removed.

Tests

  • ModelContextProtocol.Tests: 1980 passed, 0 failed, 4 skipped
  • ModelContextProtocol.AspNetCore.Tests: 410 passed, 0 failed, 33 skipped

Includes new coverage:

  • DraftProtocolGuardTests — verifies the legacy methods throw under DRAFT-2026-v1.
  • MrtrLowLevelApiTests, MrtrSerializationTests — exercise InputRequiredException and its wire format.
  • MapMcpTests.Mrtr — end-to-end Streamable HTTP coverage.
  • SEP-2322 conformance scenarios under ServerConformanceTests (8 ephemeral + 3 task-based deferred).

Docs

  • docs/concepts/mrtr/mrtr.md rewritten around InputRequiredException with the new compatibility matrix.
  • docs/concepts/elicitation/, sampling/, roots/ updated to call out the DRAFT-2026-v1 behavior change.
  • docs/concepts/tasks/tasks.mdDeferTaskCreation section removed.

@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 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 thread src/ModelContextProtocol.Core/Protocol/InputResponse.cs Outdated
Comment thread src/ModelContextProtocol.Core/Client/McpClientImpl.cs
halter73 and others added 8 commits May 27, 2026 17:16
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>
…er draft

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ve input requests with WhenAll+CTS

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

ElicitAsync/SampleAsync/RequestRootsAsync now throw only when the server is stateless (the existing ThrowIf*Unsupported guards already handled this). Stdio + DRAFT-2026-v1 keeps working via the legacy server-to-client JSON-RPC path; stateless Streamable HTTP throws regardless of protocol revision. A follow-up will force DRAFT-2026-v1 Streamable HTTP to stateless mode.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
halter73 and others added 2 commits May 28, 2026 08:37
…l framing, restore lost theory coverage

- Revert BOM-only diffs on AIFunctionMcpServerTool.cs and DelegatingMcpServerTool.cs.
- Drop the unused System.Diagnostics.CodeAnalysis using in McpServerTool.cs.
- Restore the trailing newline in McpServerToolAttribute.cs.
- Revert the NegotiatedProtocolVersion stub change in McpServerTests.cs (only the deleted ThrowIfDraftProtocol gate needed it).
- Drop the stray blank line in MapMcpTests.cs.
- Inline IsLowLevelMrtrAvailable into a public override IsMrtrSupported on McpServerImpl; DestinationBoundMcpServer.IsMrtrSupported is now a simple proxy.
- Rewrite the stale IsStatefulSession XML doc.
- Rename MrtrLowLevelApiTests -> MrtrInputRequiredExceptionTests, and drop low-level/high-level adjectives from MRTR tests + docs.
- Restore InlineData(true) on Mrtr_MixedExceptionAndAwaitStyle (covers draft+stateful mixed mode); add AssertMrtrUsedAtLeastOnce helper.
- Collapse Mrtr_ParallelAwaits to a Fact (under the new contract draft+stateful behaves the same as legacy+stateful).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… to fix docfx warnings

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: halter73 <54385+halter73@users.noreply.github.com>
…nsport to avoid GET-stream race

Server-side InputRequiredException backcompat resolver was calling this.ElicitAsync / SampleAsync / RequestRootsAsync, which routes outgoing requests through the session-level _transport. StreamableHttpServerTransport.SendMessageAsync silently drops messages when no GET request has arrived yet, so under CI load the McpClient's async GET startup could race with the in-flight tools/call, causing the resolver to wait on a TCS forever.

Route the outgoing requests through CreateDestinationBoundServer(request) instead, matching the pattern used by tool-initiated server.SampleAsync etc. Outgoing JSON-RPC then flows back through the original POST's response stream (always open during the tool call) instead of the standalone GET.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment thread Makefile Outdated
Comment thread src/ModelContextProtocol.Core/McpSessionHandler.cs Outdated
Comment thread src/ModelContextProtocol.Core/Client/McpClientImpl.cs Outdated
Comment thread src/ModelContextProtocol.Core/Server/McpServerImpl.cs Outdated
Comment thread src/ModelContextProtocol.Core/Protocol/InputRequiredResult.cs Outdated
Comment thread src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs Outdated
Comment thread src/ModelContextProtocol.Core/Client/McpClientImpl.cs Outdated
- McpSessionHandler.SendRequestAsync no longer double-wraps SendToRelatedTransportAsync in _outgoingMessageFilter and no longer duplicates the per-request logging that SendToRelatedTransportAsync already emits. Restores main's once-per-send semantics.
- McpClientImpl.ResolveInputRequestAsync gracefully handles roots/list InputRequests with no params (ListRootsRequestParams is optional per spec) by falling back to a default instance, matching the server-side resolver.
- Rename local var (McpClientImpl) and parameter (McpServerImpl.SerializeInputRequiredResult) from PascalCase 'InputRequiredResult' to camelCase 'inputRequiredResult'.
- StreamableHttpHandler.ValidateProtocolVersionHeader restored to private static (uses only a const and a static field; no instance state).
- Tighten InputRequiredResult XML doc to note that this SDK currently only wires the MRTR interceptor into tools/call, even though SEP-2322 defines the wire format for prompts/get and resources/read too.
- Tighten outgoing- and incoming-filter tests (AddOutgoingMessageFilter_Sees_Responses_Notifications_And_Requests, OutgoingFilter_SeesResponsesAndRequests, AddIncomingMessageFilter_Intercepts_Request_Messages, and AddIncomingMessageFilter_Multiple_Filters_Execute_In_Order) from substring/Contains/IndexOf checks to strict per-category counts. The substring assertions passed even when SendRequestAsync invoked the outgoing filter twice per request, so the regression went undetected; the new counts catch it (sampling/createMessage and tool-call response counts double when the bug is present). The symmetric incoming-side tightening guards against an analogous future regression on the receive pipeline.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mikekistler mikekistler linked an issue Jun 2, 2026 that may be closed by this pull request
Comment thread src/ModelContextProtocol.Core/Client/McpClientImpl.cs
tarekgh
tarekgh previously approved these changes Jun 2, 2026
Copy link
Copy Markdown
Contributor

@tarekgh tarekgh left a comment

Choose a reason for hiding this comment

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

LGTM, Thanks!

Comment thread src/ModelContextProtocol.Core/Server/McpServerImpl.cs
Comment thread src/ModelContextProtocol.Core/Client/McpClientImpl.cs Outdated
halter73 and others added 3 commits June 2, 2026 16:49
Reverts most of commit 18c0df7's removal of MrtrContext/MrtrContinuation/MrtrExchange, gating it to stateful sessions only. Tools calling ElicitAsync/SampleAsync/RequestRootsAsync under DRAFT-2026-v1 on stdio and stateful Streamable HTTP again transparently suspend the handler via TCS and emit InputRequiredResult to the client, with retries resumed via continuation lookup on requestState.

Stateless Streamable HTTP still requires explicit InputRequiredException for MRTR: the WrapHandlerWithMrtr gate skips the implicit machinery when !IsStatefulSession() and routes through InvokeWithInputRequiredResultHandlingAsync, which already throws when the client doesn't support MRTR on stateless.

Deferred-task related machinery (DeferredTask / DeferredTaskCreationResult / DeferTaskCreation / HandleDeferredTaskCreationAsync) is NOT restored. That work was superseded by SEP-2663 (PR #1579), which uses an entirely different API surface (McpServerOptions.TaskStore + per-request task metadata) and would just have to delete the restored SEP-1686 code during its rebase.

Test coverage restored: MrtrIntegrationTests (Client), MrtrHandlerLifecycleTests / MrtrMessageFilterTests / MrtrSessionLimitTests (Server), plus the deleted SessionDelete_* + RetryWithInvalidRequestState_* tests in MrtrProtocolTests and the Mrtr_ParallelAwaits theory rows in MapMcpTests.Mrtr.cs.

All previously wrapped methods (tools/call, prompts/get, resources/read) are wired through the MRTR interceptor again; updated InputRequiredResult XML doc accordingly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When an MRTR round emits an InputRequiredResult whose RequestState is null,
the next round's request must not carry a stale requestState forwarded from
a prior round's params clone. Both the client retry loop
(McpClientImpl.ResolveInputRequestsAsync) and the server backcompat resolver
(McpServerImpl.InvokeWithInputRequiredResultHandlingAsync) deep-cloned the
prior request params, overwrote requestState only when the new result
supplied one, and left any prior value untouched otherwise.

Fixed by adding the symmetric else branch that explicitly removes the
requestState key whenever the latest InputRequiredResult clears it. This
mirrors the existing paramsObj.Remove("inputResponses") in the
state-only-retry sibling branch and matches the wire-format intent that
requestState is round-scoped, not session-scoped.

Regression tests:

* MrtrIntegrationTests.IncompleteResultRetry_OmittingRequestState_StripsStaleStateFromRetryParams
  (client) — uses the fake-stream harness to send InputRequiredResult with
  requestState on round 1, then without it on round 2, and asserts the
  third retry params do not contain a "requestState" key.

* MrtrServerBackcompatTests.InputRequiredException_TransitioningRequestStateToNull_DoesNotLeakStaleState
  (server) — uses a non-MRTR client so the server falls into the backcompat
  resolver path; a tool throws InputRequiredException with requestState
  then with null, and the third handler invocation asserts
  context.Params.RequestState is null.

Both tests were verified to fail with their respective fixes reverted.

Also removed a duplicate XML inheritdoc tag above McpClientImpl.AddKnownTools per review.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Phase 1 of this restoration brought back three Core MRTR happy-path tests
that duplicate scenarios already covered by `MapMcpTests.Mrtr` theory rows
(across StreamableHttp / SSE / Stateless transports with both
`experimentalClient: true` and `experimentalClient: false`):

* `CallToolAsync_BothExperimental_ElicitCompletesViaMrtr`
  → covered by `Mrtr_MultiRoundTrip_Completes(experimentalClient: true)`
* `CallToolAsync_ConcurrentElicitAndSample_PropagatesError`
  → covered by `Mrtr_ParallelAwaits(experimentalClient: true)`
* `CallToolAsync_ElicitThenIncompleteResultException_WorksEndToEnd`
  → covered by `Mrtr_MixedExceptionAndAwaitStyle(experimentalClient: true)`

The MapMcpTests versions assert the same MrtrContext gate ("Concurrent
server-to-client requests are not supported"), the same MrtrUsed message
tracker assertions, and run against multiple transports. The Core stdio
mirrors added no transport-independent coverage.

Also removed two now-unused tool definitions in the test fixture:
`concurrent-tool`, `incomplete-result-tool`, `elicit-then-incomplete-result-tool`.

Retained the rest of MrtrIntegrationTests:
* `ClientHandlerException_DuringMrtrInputResolution_SurfacesToCaller`
  (draft client retry-loop error propagation — not covered by the legacy
  `Mrtr_Backcompat_ClientHandlerThrows_PropagatesError` which exercises
  a different code path)
* `SendMessageAsync_WithJsonRpcRequest_ThrowsAlways` (client API contract)
* `LegacyRequestOnMrtrSession_LogsWarning` (fake-stream protocol-compliance assertion)
* `IncompleteResultOnNonMrtrSession_LogsWarning` (fake-stream protocol-compliance assertion)
* `IncompleteResultRetry_OmittingRequestState_StripsStaleStateFromRetryParams` (regression test)

The server-side lifecycle tests (MrtrHandlerLifecycleTests), message
filter tests (MrtrMessageFilterTests), and per-session limit pattern tests
(MrtrSessionLimitTests) are intentionally kept — they exercise the internal
MRTR machinery (continuation cancellation, handler drain, outgoing filter
visibility) which is not naturally testable at the HTTP transport layer.

Test counts: Core 2024 → 2021 (-3); AspNetCore unchanged at 435.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
tarekgh
tarekgh previously approved these changes Jun 3, 2026
Comment thread src/ModelContextProtocol.Core/Server/McpServerImpl.cs
Comment thread src/ModelContextProtocol.Core/Server/McpServerImpl.cs Outdated
Comment thread src/ModelContextProtocol.Core/Server/MrtrContext.cs Outdated
Comment thread docs/concepts/elicitation/elicitation.md
PranavSenthilnathan (MrtrContext.cs:67): the "non-English characters" in the
MRTR files were UTF-8 em-dash bytes (E2 80 94) introduced when MRTR machinery
was restored from a prior commit. Replaced every em-dash in the PR's .cs files
with an ASCII hyphen ('-') so the source is plain ASCII and viewer/encoding
mishaps can't repeat (18 files, 64 occurrences).

tarekgh (McpServerImpl.cs:1549): the doc comment on
ObserveHandlerCompletionAsync claimed it logged unhandled exceptions at Error
level, but MrtrHandlerError is declared at LogLevel.Debug. Updated the comment
to match, with a note that Debug avoids double-reporting since the exception
still propagates through the request pipeline.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@halter73 halter73 dismissed stale reviews from PranavSenthilnathan and tarekgh via 877ce83 June 3, 2026 21:04
@halter73 halter73 merged commit f6dbe43 into main Jun 3, 2026
22 of 24 checks passed
@halter73 halter73 deleted the halter73/mrtr branch June 3, 2026 23:19
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.

SEP-2322: Multi Round-Trip Requests

4 participants