Skip to content

Multi Round-Trip Requests (MRTR)#1458

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

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

3 participants