Multi Round-Trip Requests (MRTR)#1458
Conversation
f1dd4c4 to
5845866
Compare
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>
…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>
MrtrProtocolTests.BackcompatResolver_SendsServerRequestOverPostStream_WithoutGetStream deliberately never opens a GET stream, so it deterministically fails if the server's backcompat resolver routes its outgoing roots/list request through the session-level transport instead of the POST's RelatedTransport. Verified the test hangs/fails with the fix reverted and passes with it applied. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- 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>
| if (inputRequiredResult.RequestState is { } requestState) | ||
| { | ||
| paramsObj["requestState"] = requestState; | ||
| } |
There was a problem hiding this comment.
In the input-requests branch, requestState is only set when the new InputRequiredResult carries one, but it's never removed from the cloned params. If an earlier round returned a requestState and a later round returns input requests without one, the stale value survives the DeepClone() and gets echoed back to the server. That breaks the opaque-state echo semantics: the client should echo the state from the current incomplete result, not an older one the server no longer emitted (and it can keep replaying state the server intended to drop).
Note the asymmetry with the requestState-only branch just below, which explicitly calls paramsObj.Remove(""inputResponses""). This branch should mirror that:
| if (inputRequiredResult.RequestState is { } requestState) | |
| { | |
| paramsObj["requestState"] = requestState; | |
| } | |
| if (inputRequiredResult.RequestState is { } requestState) | |
| { | |
| paramsObj["requestState"] = requestState; | |
| } | |
| else | |
| { | |
| paramsObj.Remove("requestState"); | |
| } |
The server-side back-compat resolver in McpServerImpl.InvokeWithInputRequiredResultHandlingAsync (around lines 1219-1227) has the same pattern and needs the same fix. The existing multi-round test always supplies requestState on every round, so this transition isn't currently covered; worth adding a case that drops requestState mid-sequence.
| { | ||
| return await handler(request, cancellationToken).ConfigureAwait(false); | ||
| } | ||
| catch (InputRequiredException ex) |
There was a problem hiding this comment.
The experimental tasks API and the new one in #1579 handle input required in different ways than this.
The experimental tasks code allows the user to call ElicitAsync (since it's still valid as part of that spec) in the middle of a server tool call execution, but it adds the task id to the request. It does this by having a task local context that carries around the current MCP task id of the executing request.
#1579 handles this somewhat similarly. It allows the server tool call execution to call ElicitAsync, but instead just updates the task state to an InputRequiredTask instead of sending the request to the client.
In both cases, we let the tool implementor to call elicit/sample/etc., but this PR introduces an InputRequiredException instead. I'm wondering if there's a benefit in doing it this way instead of the other. Having input requests surface as exceptions doesn't seem intuitive (to me at least).
There was a problem hiding this comment.
The key difference between MRTR and elicitation via tasks is there's no ID for the SDK to key off of. When I first opened this PR as a draft in March, it was using the Mcp-Session-Id header to find the ElicitAsync, SampleAsync and RootsAsync continuations.
With this PR, if you do an ElicitAsync with the draft protocol version and the stdio transport, we do the magic InputRequiredResult translation and then continue after ElicitAsync with the ElicitResult(s) from the InputRequest(s).
However, in stateless Streamable HTTP, we would be forced to keep a global continuation dictionary to support ElicitAsync et al., which would still be per-process and cause problems with load balancing, surviving server restarts etc. It's better to force people writing Streamable HTTP MCP servers to manually handle the InputRequest(s) and RequestState themselves, so they can write truly stateless handlers that work even if the elicitation/sampling/roots results end up going to a different process.
There was a problem hiding this comment.
Correction. This is how I intended things to be, but I had reverted it. I added this behavior back with ff6372a. Please take a look.
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>
| private readonly McpSessionHandler _sessionHandler; | ||
| private readonly SemaphoreSlim _disposeLock = new(1, 1); | ||
| private readonly McpTaskCancellationTokenProvider? _taskCancellationTokenProvider; | ||
| private readonly ConcurrentDictionary<string, MrtrContinuation> _mrtrContinuations = new(); |
There was a problem hiding this comment.
Unbounded _mrtrContinuations for abandoned stateful MRTR flows
The implicit stateful path parks an MrtrContinuation in _mrtrContinuations[correlationId] whenever a handler suspends, and only removes it on a matching retry (TryRemove(requestState)) or in DisposeAsync. There's no size cap and no idle timeout, so a stateful client that starts MRTR flows (ElicitAsync/SampleAsync/RequestRootsAsync) and never sends the retry leaves each continuation resident for the life of the session — a parked handler task plus the intentionally-never-disposed CancellationTokenSource and all captured state. That's client-controlled, unbounded growth on long-lived stateful sessions.
Note this is asymmetric with the other path: InvokeWithInputRequiredResultHandlingAsync is bounded by const int MaxRetries = 10, but that only works because it drives all rounds synchronously within a single request. The implicit path spans multiple requests with no equivalent guard.
MrtrSessionLimitTests shows an app can govern this via outgoing/incoming message filters, but it defaults to int.MaxValue (unlimited) — so out of the box the SDK enforces nothing.
Could we add a default safeguard here; e.g. a per-session cap on concurrent parked continuations (fail the suspend with an error past the limit), or an idle timeout that cancels via the existing handler CTS and evicts the entry? If the intent is to leave governance entirely to the application, that's a defensible choice, but it should be called out explicitly in the MRTR docs along with the filter-based mitigation pattern from MrtrSessionLimitTests.
There was a problem hiding this comment.
I see this as no different than the way you can have unlimited McpSessionHandler._pendingRequests. It is possible to limit this at the application layer by just not making more requests if too many pile up. If we address it for this, I think we should do so for all pending requests in a follow up PR.
| } | ||
|
|
||
| /// <summary> | ||
| /// Fire-and-forget observer for an MRTR handler task. Logs unhandled exceptions at Error |
There was a problem hiding this comment.
Doc says "Error level" but the logger is Debug
The summary states it "Logs unhandled exceptions at Error level," but MrtrHandlerError is declared Level = LogLevel.Debug. Debug looks like the right choice (the same exception still propagates to the request pipeline, so Error would double-report), so I'd just update the doc comment to match — replace "at Error level" with "at Debug level".
| var exchange = new MrtrExchange(key, inputRequest, tcs); | ||
|
|
||
| // TrySetResult is the sole atomicity gate. If it returns false, | ||
| // the TCS was already completed by a prior call ΓÇö concurrent exchanges |
|
|
||
| ### Multi Round-Trip Requests (MRTR) | ||
|
|
||
| [MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `DRAFT-2026-v1`. Under the draft protocol, the server-to-client `elicitation/create` request method is removed; the recommended way to ask the user for input from a server handler is to throw <xref:ModelContextProtocol.Protocol.InputRequiredException> and let the SDK emit an <xref:ModelContextProtocol.Protocol.InputRequiredResult> on the wire. |
There was a problem hiding this comment.
Tasks would similarly need their own exception to indicate that the SDK needs to emit a CreateTaskResult. It would be nice to have a union here, so the user and our code know all the valid return types that are supported. Since C# doesn't have unions yet, we could consider our own custom type. As long as we can implement the new IUnion interface on it once the union feature lands in C#, we should be able to get the benefits (e.g. exhaustiveness checking).
There was a problem hiding this comment.
I think something like IUnion should be saved for a follow up. I'm hesitant to add a custom type for this when C# Unions are so close. I'd rather just support those when they arrive. I can see the argument for it if it allows us to get rid of the exception, but I think it can be handled as a follow up.
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>
877ce83
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/callwithinputResponsesattached, 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
InputRequiredExceptionis the only way to do MRTR. A tool throws it with anInputRequiredResultcontaininginputRequestsand/orrequestState, and the SDK turns that into the right wire response for the negotiated protocol.McpServer.IsMrtrSupportedreturnstruewhenever the SDK can satisfyInputRequiredException— either natively (draft) or via the legacy resolver (current+stateful).Compatibility matrix
DRAFT-2026-v1InputRequiredResultis serialized straight to the wire.2025-06-18and earlier)elicitation/create/sampling/createMessage/roots/listrequests, collects responses, retries the handler withinputResponses. Capped at 10 rounds.InputRequiredExceptionraises anMcpException.Breaking changes under
DRAFT-2026-v1The draft revision removes the server-to-client
elicitation/create,sampling/createMessage, androots/listrequest methods. The SDK fails fast:McpServer.ElicitAsync,SampleAsync,RequestRootsAsync,AsSamplingChatClient,ElicitAsTaskAsync,SampleAsTaskAsyncall throwInvalidOperationExceptionafter aDRAFT-2026-v1session is negotiated. The exception message points to theInputRequest.ForElicitation/ForSampling/ForRootsListreplacement.Removed
ElicitAsync/SampleAsynccalls and suspended the handler across MRTR rounds. (Replaced by the explicitInputRequiredExceptioncontract.)DeferTaskCreationon[McpServerTool]/McpServerToolCreateOptionsand the server-sideCreateTaskAsyncAPI tied to it. Long-running tasks still useIMcpTaskStoreas before.ExperimentalProtocolVersionopt-in — replaced by negotiatingDRAFT-2026-v1directly.Follow-ups (intentionally left out of this PR)
Mcp-Session-Idand theStatefulmode. When that lands, the current-protocol-stateful row of the matrix collapses into the stateless row, and the legacyelicitation/create/sampling/createMessage/roots/listresolver path can be deleted. The code has// TODO(stateless-draft):markers where that simplification will go.tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTestsand gated onNodeHelpers.HasMrtrScenarios(). Once conformance#188 merges, the gate can be removed.Tests
ModelContextProtocol.Tests: 1980 passed, 0 failed, 4 skippedModelContextProtocol.AspNetCore.Tests: 410 passed, 0 failed, 33 skippedIncludes new coverage:
DraftProtocolGuardTests— verifies the legacy methods throw underDRAFT-2026-v1.MrtrLowLevelApiTests,MrtrSerializationTests— exerciseInputRequiredExceptionand its wire format.MapMcpTests.Mrtr— end-to-end Streamable HTTP coverage.ServerConformanceTests(8 ephemeral + 3 task-based deferred).Docs
docs/concepts/mrtr/mrtr.mdrewritten aroundInputRequiredExceptionwith the new compatibility matrix.docs/concepts/elicitation/,sampling/,roots/updated to call out theDRAFT-2026-v1behavior change.docs/concepts/tasks/tasks.md—DeferTaskCreationsection removed.