Skip to content

Multi Round-Trip Requests (MRTR)#1458

Open
halter73 wants to merge 20 commits into
mainfrom
halter73/mrtr
Open

Multi Round-Trip Requests (MRTR)#1458
halter73 wants to merge 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
@halter73 halter73 marked this pull request as ready for review May 28, 2026 17:42
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>
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 on lines +876 to +879
if (inputRequiredResult.RequestState is { } requestState)
{
paramsObj["requestState"] = requestState;
}
Copy link
Copy Markdown
Contributor

@tarekgh tarekgh Jun 2, 2026

Choose a reason for hiding this comment

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

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:

Suggested change
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.

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!

{
return await handler(request, cancellationToken).ConfigureAwait(false);
}
catch (InputRequiredException ex)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

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
private readonly McpSessionHandler _sessionHandler;
private readonly SemaphoreSlim _disposeLock = new(1, 1);
private readonly McpTaskCancellationTokenProvider? _taskCancellationTokenProvider;
private readonly ConcurrentDictionary<string, MrtrContinuation> _mrtrContinuations = new();
Copy link
Copy Markdown
Contributor

@tarekgh tarekgh Jun 3, 2026

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

@halter73 halter73 Jun 3, 2026

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

non-English characters


### 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.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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>
@halter73 halter73 dismissed stale reviews from PranavSenthilnathan and tarekgh via 877ce83 June 3, 2026 21:04
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