From fb78b76478e9d61aad8e688d7b926d8a9f51bf1e Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 19 May 2026 07:31:52 -0700 Subject: [PATCH 01/14] Rebase MRTR onto main (squash merge with conflict resolution) Resolves conflicts from rebasing the MRTR work (originally branched from 4140c6d6) onto the current main (b8c4d951). 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> --- docs/concepts/elicitation/elicitation.md | 76 ++ docs/concepts/index.md | 1 + docs/concepts/mrtr/mrtr.md | 464 ++++++++++ docs/concepts/roots/roots.md | 52 ++ docs/concepts/sampling/sampling.md | 73 ++ docs/concepts/tasks/tasks.md | 52 ++ docs/concepts/toc.yml | 2 + src/Common/Experimentals.cs | 19 + .../ModelContextProtocol.AspNetCore.csproj | 1 + .../StreamableHttpHandler.cs | 26 +- .../Client/McpClientImpl.cs | 190 +++- .../Client/McpClientOptions.cs | 20 + .../McpJsonUtilities.cs | 7 + src/ModelContextProtocol.Core/McpSession.cs | 2 +- .../McpSessionHandler.cs | 26 +- .../Protocol/IncompleteResult.cs | 63 ++ .../Protocol/IncompleteResultException.cs | 109 +++ .../Protocol/InputRequest.cs | 197 +++++ .../Protocol/InputResponse.cs | 127 +++ .../Protocol/JsonRpcMessageContext.cs | 11 + .../Protocol/RequestParams.cs | 47 + .../Protocol/Result.cs | 14 + .../Server/AIFunctionMcpServerTool.cs | 14 +- .../Server/DeferredTaskCreationResult.cs | 27 + .../Server/DeferredTaskInfo.cs | 78 ++ .../Server/DelegatingMcpServerTool.cs | 3 + .../Server/DestinationBoundMcpServer.cs | 67 +- .../Server/McpServer.cs | 53 ++ .../Server/McpServerImpl.cs | 647 +++++++++++++- .../Server/McpServerOptions.cs | 19 + .../Server/McpServerTool.cs | 8 + .../Server/McpServerToolAttribute.cs | 25 + .../Server/McpServerToolCreateOptions.cs | 15 + .../Server/MrtrContext.cs | 85 ++ .../Server/MrtrContinuation.cs | 50 ++ .../Server/MrtrExchange.cs | 41 + tests/Common/Utils/ServerMessageTracker.cs | 85 ++ .../MapMcpStatelessTests.cs | 40 +- .../MapMcpStreamableHttpTests.cs | 39 +- .../MapMcpTests.Mrtr.cs | 818 ++++++++++++++++++ .../MapMcpTests.cs | 51 +- .../MrtrProtocolTests.cs | 289 +++++++ .../McpClientDeferredTaskCreationTests.cs | 334 +++++++ .../Client/McpClientTests.cs | 8 + .../Client/MrtrIntegrationTests.cs | 621 +++++++++++++ .../Protocol/MrtrSerializationTests.cs | 295 +++++++ .../Server/MrtrHandlerLifecycleTests.cs | 438 ++++++++++ .../Server/MrtrLowLevelApiTests.cs | 62 ++ .../Server/MrtrMessageFilterTests.cs | 149 ++++ .../Server/MrtrSessionLimitTests.cs | 183 ++++ .../Server/MrtrTaskIntegrationTests.cs | 295 +++++++ 51 files changed, 6327 insertions(+), 91 deletions(-) create mode 100644 docs/concepts/mrtr/mrtr.md create mode 100644 src/ModelContextProtocol.Core/Protocol/IncompleteResult.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/IncompleteResultException.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/InputRequest.cs create mode 100644 src/ModelContextProtocol.Core/Protocol/InputResponse.cs create mode 100644 src/ModelContextProtocol.Core/Server/DeferredTaskCreationResult.cs create mode 100644 src/ModelContextProtocol.Core/Server/DeferredTaskInfo.cs create mode 100644 src/ModelContextProtocol.Core/Server/MrtrContext.cs create mode 100644 src/ModelContextProtocol.Core/Server/MrtrContinuation.cs create mode 100644 src/ModelContextProtocol.Core/Server/MrtrExchange.cs create mode 100644 tests/Common/Utils/ServerMessageTracker.cs create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Client/McpClientDeferredTaskCreationTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Protocol/MrtrSerializationTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/MrtrLowLevelApiTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/MrtrTaskIntegrationTests.cs diff --git a/docs/concepts/elicitation/elicitation.md b/docs/concepts/elicitation/elicitation.md index 94597fa5f..4c99cc076 100644 --- a/docs/concepts/elicitation/elicitation.md +++ b/docs/concepts/elicitation/elicitation.md @@ -170,6 +170,82 @@ Here's an example implementation of how a console application might handle elici [!code-csharp[](samples/client/Program.cs?name=snippet_ElicitationHandler)] +### Multi Round-Trip Requests (MRTR) + +When both the client and server opt in to the experimental [MRTR](xref:mrtr) protocol, elicitation requests are handled via incomplete result / retry instead of a direct JSON-RPC request. This is transparent — the existing `ElicitAsync` API works identically regardless of whether MRTR is active. + +#### High-level API + +No code changes are needed. `ElicitAsync` automatically uses MRTR when both sides have opted in, and falls back to legacy JSON-RPC requests otherwise: + +```csharp +// This code works the same with or without MRTR — the SDK handles it transparently. +var result = await server.ElicitAsync(new ElicitRequestParams +{ + Message = "Please confirm the action", + RequestedSchema = new() + { + Properties = new Dictionary + { + ["confirm"] = new ElicitRequestParams.BooleanSchema + { + Description = "Confirm the action" + } + } + } +}, cancellationToken); +``` + +#### Low-level API + +For stateless servers or scenarios requiring manual control, throw with an elicitation input request. On retry, read the client's response from : + +```csharp +[McpServerTool, Description("Tool that elicits via low-level MRTR")] +public static string ElicitWithMrtr( + McpServer server, + RequestContext context) +{ + // On retry, process the client's elicitation response + if (context.Params!.InputResponses?.TryGetValue("user_input", out var response) is true) + { + var elicitResult = response.ElicitationResult; + return elicitResult?.Action == "accept" + ? $"User accepted: {elicitResult.Content?.FirstOrDefault().Value}" + : "User declined."; + } + + if (!server.IsMrtrSupported) + { + return "This tool requires MRTR support."; + } + + // First call — request user input + throw new IncompleteResultException( + inputRequests: new Dictionary + { + ["user_input"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "Please confirm the action", + RequestedSchema = new() + { + Properties = new Dictionary + { + ["confirm"] = new ElicitRequestParams.BooleanSchema + { + Description = "Confirm the action" + } + } + } + }) + }, + requestState: "awaiting-confirmation"); +} +``` + +> [!TIP] +> See [Multi Round-Trip Requests (MRTR)](xref:mrtr) for the full protocol details, including multiple round trips, concurrent input requests, and the compatibility matrix. + ### URL Elicitation Required Error When a tool cannot proceed without first completing a URL-mode elicitation (for example, when third-party OAuth authorization is needed), and calling `ElicitAsync` is not practical (for example in [stateless](xref:stateless) mode where server-to-client requests are disabled), the server may throw a . This is a specialized error (JSON-RPC error code `-32042`) that signals to the client that one or more URL-mode elicitations must be completed before the original request can be retried. diff --git a/docs/concepts/index.md b/docs/concepts/index.md index 6393d9997..9e5a90f25 100644 --- a/docs/concepts/index.md +++ b/docs/concepts/index.md @@ -18,6 +18,7 @@ Install the SDK and build your first MCP client and server. | [Progress tracking](progress/progress.md) | Learn how to track progress for long-running operations through notification messages. | | [Cancellation](cancellation/cancellation.md) | Learn how to cancel in-flight MCP requests using cancellation tokens and notifications. | | [Tasks](tasks/tasks.md) | Learn how to use task-based execution for long-running operations that can be polled for status and results. | +| [Multi Round-Trip Requests (MRTR)](mrtr/mrtr.md) | Learn how servers request client input during tool execution using input-required results and retries. | ### Client Features diff --git a/docs/concepts/mrtr/mrtr.md b/docs/concepts/mrtr/mrtr.md new file mode 100644 index 000000000..0854ef97d --- /dev/null +++ b/docs/concepts/mrtr/mrtr.md @@ -0,0 +1,464 @@ +--- +title: Multi Round-Trip Requests (MRTR) +author: halter73 +description: How servers request client input during tool execution using Multi Round-Trip Requests. +uid: mrtr +--- + +# Multi Round-Trip Requests (MRTR) + + +> [!WARNING] +> MRTR is an **experimental feature** based on a draft MCP specification proposal. The API may change in future releases. See the [Experimental APIs](../../experimental.md) documentation for details on working with experimental APIs. Both the client and server must opt in via and respectively. + +Multi Round-Trip Requests (MRTR) allow a server tool to request input from the client — such as [elicitation](xref:elicitation), [sampling](xref:sampling), or [roots](xref:roots) — as part of a single tool call, without requiring a separate JSON-RPC request for each interaction. Instead of sending a final result, the server returns an **incomplete result** containing one or more input requests. The client fulfills those requests and retries the original tool call with the responses attached. + +## Overview + +MRTR is useful when: + +- A tool needs user confirmation before proceeding (elicitation) +- A tool needs LLM reasoning from the client (sampling) +- A tool needs an updated list of client roots +- A tool needs to perform multiple rounds of interaction in a single logical operation +- A stateless server needs to orchestrate multi-step flows without keeping handler state in memory + +## How MRTR works + +1. The client calls a tool on the server via `tools/call`. +2. The server tool determines it needs client input and returns an `IncompleteResult` containing `inputRequests` and/or `requestState`. +3. The client resolves each input request (e.g., prompts the user for elicitation, calls an LLM for sampling). +4. The client retries the original `tools/call` with `inputResponses` (keyed to the input requests) and `requestState` echoed back. +5. The server processes the responses and either returns a final result or another `IncompleteResult` for additional rounds. + +## Opting in + +MRTR requires both the client and server to opt in by setting `ExperimentalProtocolVersion` to a draft protocol version. Currently, this is `"2026-06-XX"`: + +```csharp +// Server +var builder = Host.CreateApplicationBuilder(); +builder.Services.AddMcpServer(options => +{ + options.ExperimentalProtocolVersion = "2026-06-XX"; +}) +.WithTools(); +``` + +```csharp +// Client +var options = new McpClientOptions +{ + ExperimentalProtocolVersion = "2026-06-XX", + Handlers = new McpClientHandlers + { + ElicitationHandler = HandleElicitationAsync, + SamplingHandler = HandleSamplingAsync, + } +}; +``` + +When both sides opt in, the negotiated protocol version activates MRTR. When either side does not opt in, the SDK gracefully falls back to standard behavior. + +## High-level API + +The high-level API lets tool handlers call and as if they were simple async calls. The SDK transparently manages the incomplete result / retry cycle. + +```csharp +[McpServerToolType] +public class InteractiveTools +{ + [McpServerTool, Description("Asks the user for confirmation before proceeding")] + public static async Task ConfirmAction( + McpServer server, + [Description("The action to confirm")] string action, + CancellationToken cancellationToken) + { + var result = await server.ElicitAsync(new ElicitRequestParams + { + Message = $"Do you want to proceed with: {action}?", + RequestedSchema = new() + { + Properties = new Dictionary + { + ["confirm"] = new ElicitRequestParams.BooleanSchema + { + Description = "Confirm the action" + } + } + } + }, cancellationToken); + + return result.Action == "accept" ? "Action confirmed!" : "Action cancelled."; + } +} +``` + +From the client's perspective, this is a single `CallToolAsync` call. The SDK handles all retries automatically: + +```csharp +var result = await client.CallToolAsync("ConfirmAction", new { action = "delete all files" }); +Console.WriteLine(result.Content.OfType().First().Text); +``` + +> [!TIP] +> The high-level API requires session affinity — the handler task stays suspended in server memory between round trips. This works well for stateful (non-stateless) server configurations. + +## Low-level API + +The low-level API gives tool handlers direct control over `inputRequests` and `requestState`. This enables stateless multi-round-trip flows where the server does not need to keep handler state in memory between retries. + +### Checking MRTR support + +Before using the low-level API, check to determine if the connected client supports MRTR. If it does not, provide a fallback experience: + +```csharp +[McpServerTool, Description("A tool that uses low-level MRTR")] +public static string MyTool( + McpServer server, + RequestContext context) +{ + if (!server.IsMrtrSupported) + { + return "This tool requires a client that supports multi-round-trip requests. " + + "Please upgrade your client or enable experimental protocol support."; + } + + // ... MRTR logic +} +``` + +### Returning an incomplete result + +Throw to return an incomplete result to the client. The exception carries an containing `inputRequests` and/or `requestState`: + +```csharp +[McpServerTool, Description("Stateless tool managing its own MRTR flow")] +public static string StatelessTool( + McpServer server, + RequestContext context, + [Description("The user's question")] string question) +{ + var requestState = context.Params!.RequestState; + var inputResponses = context.Params!.InputResponses; + + // On retry, process the client's responses + if (requestState is not null && inputResponses is not null) + { + var elicitResult = inputResponses["user_answer"].ElicitationResult; + return $"You answered: {elicitResult?.Content?.FirstOrDefault().Value}"; + } + + if (!server.IsMrtrSupported) + { + return "MRTR is not supported by this client."; + } + + // First call — request user input + throw new IncompleteResultException( + inputRequests: new Dictionary + { + ["user_answer"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = $"Please answer: {question}", + RequestedSchema = new() + { + Properties = new Dictionary + { + ["answer"] = new ElicitRequestParams.StringSchema + { + Description = "Your answer" + } + } + } + }) + }, + requestState: "awaiting-answer"); +} +``` + +### Accessing retry data + +When the client retries a tool call, the retry data is available on the request parameters: + +- — a dictionary of client responses keyed by the same keys used in `inputRequests` +- — the opaque state string echoed back by the client + +Each `InputResponse` has typed accessors for the response type: + +- `ElicitationResult` — the result of an elicitation request +- `SamplingResult` — the result of a sampling request +- `RootsResult` — the result of a roots list request + +### Load shedding with requestState-only responses + +A server can return a `requestState`-only incomplete result (without any `inputRequests`) to defer processing. This is useful for load shedding or breaking up long-running work across multiple requests: + +```csharp +[McpServerTool, Description("Tool that defers work using requestState")] +public static string DeferredTool( + McpServer server, + RequestContext context) +{ + var requestState = context.Params!.RequestState; + + if (requestState is not null) + { + // Resume deferred work + var state = JsonSerializer.Deserialize( + Convert.FromBase64String(requestState)); + return $"Completed step {state!.Step}"; + } + + if (!server.IsMrtrSupported) + { + return "MRTR is not supported by this client."; + } + + // Defer work to a later retry + var initialState = new MyState { Step = 1 }; + throw new IncompleteResultException( + requestState: Convert.ToBase64String( + JsonSerializer.SerializeToUtf8Bytes(initialState))); +} +``` + +The client automatically retries `requestState`-only incomplete results, echoing the state back without needing to resolve any input requests. + +### Multiple round trips + +A tool can perform multiple rounds of interaction by throwing `IncompleteResultException` multiple times across retries: + +```csharp +[McpServerTool, Description("Multi-step wizard")] +public static string WizardTool( + McpServer server, + RequestContext context) +{ + var requestState = context.Params!.RequestState; + var inputResponses = context.Params!.InputResponses; + + if (requestState == "step-2" && inputResponses is not null) + { + var name = inputResponses["name"].ElicitationResult?.Content?.FirstOrDefault().Value; + var age = inputResponses["age"].ElicitationResult?.Content?.FirstOrDefault().Value; + return $"Welcome, {name}! You are {age} years old."; + } + + if (requestState == "step-1" && inputResponses is not null) + { + var name = inputResponses["name"].ElicitationResult?.Content?.FirstOrDefault().Value; + + // Second round — ask for age + throw new IncompleteResultException( + inputRequests: new Dictionary + { + ["age"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = $"Hi {name}! How old are you?", + RequestedSchema = new() + { + Properties = new Dictionary + { + ["age"] = new ElicitRequestParams.NumberSchema + { + Description = "Your age" + } + } + } + }) + }, + requestState: "step-2"); + } + + if (!server.IsMrtrSupported) + { + return "MRTR is not supported. Please use a compatible client."; + } + + // First round — ask for name + throw new IncompleteResultException( + inputRequests: new Dictionary + { + ["name"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "What's your name?", + RequestedSchema = new() + { + Properties = new Dictionary + { + ["name"] = new ElicitRequestParams.StringSchema + { + Description = "Your name" + } + } + } + }) + }, + requestState: "step-1"); +} +``` + +### Providing custom error messages + +When MRTR is not supported, you can provide domain-specific guidance: + +```csharp +if (!server.IsMrtrSupported) +{ + return "This tool requires interactive input, but your client doesn't support " + + "multi-round-trip requests. To use this feature:\n" + + "1. Update to a client that supports MCP protocol version 2026-06-XX or later\n" + + "2. Enable the experimental protocol version in your client configuration\n" + + "\nFor more information, see: https://example.com/mrtr-setup"; +} +``` + +## Compatibility + +The SDK handles all four combinations of experimental/non-experimental client and server: + +| Server Experimental | Client Experimental | Behavior | +|---|---|---| +| ✅ | ✅ | MRTR — incomplete results with retry cycle | +| ✅ | ❌ | Server falls back to legacy JSON-RPC requests for elicitation/sampling | +| ❌ | ✅ | Client accepts stable protocol version; MRTR retry loop is a no-op | +| ❌ | ❌ | Standard behavior — no MRTR | + +When a server has MRTR enabled but the connected client does not: + +- The high-level API (`ElicitAsync`, `SampleAsync`) automatically falls back to sending standard JSON-RPC requests — no code changes needed. +- The low-level API reports `IsMrtrSupported == false`, allowing the tool to provide a custom fallback message. + +### Backward compatibility for MRTR-native tools + +Tools written with the low-level MRTR pattern (`IncompleteResultException`) 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. + +This means you can write a single tool implementation using the MRTR-native pattern and it will work with any client: + +```csharp +[McpServerTool, Description("Get weather with user's preferred units")] +public static string GetWeather( + RequestContext context, + string location) +{ + // On retry, inputResponses and requestState are populated + if (context.Params!.InputResponses?.TryGetValue("units", out var response) == true) + { + var units = response.ElicitationResult?.Content?.FirstOrDefault().Value; + return $"Weather for {location} in {units}: 72°"; + } + + // First call: request the user's preferred units + throw new IncompleteResultException( + inputRequests: new Dictionary + { + ["units"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "Which temperature units?", + RequestedSchema = new() + }) + }, + requestState: "awaiting-units"); +} +``` + +- **With an MRTR client**: The `IncompleteResult` is sent over the wire. The client resolves the elicitation and retries with `inputResponses`. +- **Without MRTR**: The SDK sends a standard `elicitation/create` JSON-RPC request to the client, collects the response, and retries the handler internally. The client never sees the `IncompleteResult`. + +> [!NOTE] +> The backcompat retry loop resolves up to 10 rounds. Tools that need more rounds should use the high-level API (`ElicitAsync`) instead. + +## Transitioning from MRTR to Tasks + + +> [!WARNING] +> Deferred task creation depends on both the [MRTR](xref:mrtr) and [Tasks](xref:tasks) experimental features. + +Some tools need user input before they can decide whether to start a long-running background task. For example, a VM provisioning tool might confirm costs with the user before committing to a task that takes minutes. **Deferred task creation** lets a tool perform ephemeral MRTR exchanges first, then transition to a background task only when ready. + +### How it works + +1. The tool sets `DeferTaskCreation = true` on its attribute or options. +2. When the client sends task metadata with the `tools/call` request, the SDK runs the tool through the normal MRTR-wrapped path instead of creating a task immediately. +3. The tool calls `ElicitAsync` or `SampleAsync` as usual — these use MRTR (incomplete result / retry cycles). +4. When the tool is ready, it calls `await server.CreateTaskAsync(cancellationToken)` to transition to a background task. +5. After `CreateTaskAsync`, the MRTR phase ends. Any subsequent `ElicitAsync` or `SampleAsync` calls use the task's own `input_required` / `tasks/input_response` mechanism instead. +6. If the tool returns without calling `CreateTaskAsync`, a normal (non-task) result is sent to the client. + +### Server example + +```csharp +McpServerTool.Create( + async (string vmName, McpServer server, CancellationToken ct) => + { + // Phase 1: Ephemeral MRTR — confirm with user before starting expensive work. + var confirmation = await server.ElicitAsync(new ElicitRequestParams + { + Message = $"Provision VM '{vmName}'? This will incur costs.", + RequestedSchema = new() + }, ct); + + if (confirmation.Action != "confirm") + { + return "Cancelled by user."; + } + + // Phase 2: Transition to a background task. + await server.CreateTaskAsync(ct); + + // Phase 3: Background work — runs as a task, client polls for status. + await Task.Delay(TimeSpan.FromMinutes(5), ct); + return $"VM '{vmName}' provisioned successfully."; + }, + new McpServerToolCreateOptions + { + Name = "provision-vm", + Description = "Provisions a VM with user confirmation", + DeferTaskCreation = true, + Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional }, + }) +``` + +The attribute-based equivalent uses `DeferTaskCreation` on : + +```csharp +[McpServerTool(DeferTaskCreation = true, TaskSupport = ToolTaskSupport.Optional)] +[Description("Provisions a VM with user confirmation")] +public static async Task ProvisionVm( + string vmName, McpServer server, CancellationToken ct) +{ + var confirmation = await server.ElicitAsync(new ElicitRequestParams + { + Message = $"Provision VM '{vmName}'? This will incur costs.", + RequestedSchema = new() + }, ct); + + if (confirmation.Action != "confirm") + return "Cancelled by user."; + + await server.CreateTaskAsync(ct); + + await Task.Delay(TimeSpan.FromMinutes(5), ct); + return $"VM '{vmName}' provisioned successfully."; +} +``` + +### Key points + +- **One-way transition**: Once `CreateTaskAsync` is called, the tool cannot go back to ephemeral MRTR. All subsequent input requests use the task workflow. +- **Optional task creation**: A `DeferTaskCreation` tool can return a normal result without ever calling `CreateTaskAsync`. The tool decides at runtime whether to create a task. +- **No task metadata, no deferral**: If the client calls the tool without task metadata, the tool runs normally with MRTR — `DeferTaskCreation` has no effect. + +For more details on task configuration and lifecycle, see the [Tasks](xref:tasks) documentation. + +## Choosing between high-level and low-level APIs + +| Consideration | High-level API | Low-level API | +|---|---|---| +| **Session affinity** | Required — handler stays suspended in memory | Not required — handler completes each round | +| **State management** | Automatic (SDK manages via `MrtrContext`) | Manual (`requestState` encoded by you) | +| **Complexity** | Simple `await` calls | More code, but full control | +| **Stateless servers** | Not compatible | Designed for stateless scenarios | +| **Fallback** | Automatic — SDK sends legacy requests | Manual — check `IsMrtrSupported` | +| **Multiple input types** | One at a time (elicit or sample) | Multiple in a single round | diff --git a/docs/concepts/roots/roots.md b/docs/concepts/roots/roots.md index 7c09e53ad..65c45d91b 100644 --- a/docs/concepts/roots/roots.md +++ b/docs/concepts/roots/roots.md @@ -103,3 +103,55 @@ server.RegisterNotificationHandler( Console.WriteLine($"Roots updated. {result.Roots.Count} roots available."); }); ``` + +### Multi Round-Trip Requests (MRTR) + +When both the client and server opt in to the experimental [MRTR](xref:mrtr) protocol, root list requests are handled via incomplete result / retry instead of a direct JSON-RPC request. This is transparent — the existing `RequestRootsAsync` API works identically regardless of whether MRTR is active. + +#### High-level API + +No code changes are needed. `RequestRootsAsync` automatically uses MRTR when both sides have opted in: + +```csharp +// This code works the same with or without MRTR — the SDK handles it transparently. +var result = await server.RequestRootsAsync(new ListRootsRequestParams(), cancellationToken); +foreach (var root in result.Roots) +{ + Console.WriteLine($"Root: {root.Name ?? root.Uri}"); +} +``` + +#### Low-level API + +For stateless servers or scenarios requiring manual control, throw with a roots input request. On retry, read the client's response from : + +```csharp +[McpServerTool, Description("Tool that requests roots via low-level MRTR")] +public static string ListRootsWithMrtr( + McpServer server, + RequestContext context) +{ + // On retry, process the client's roots response + if (context.Params!.InputResponses?.TryGetValue("get_roots", out var response) is true) + { + var roots = response.RootsResult?.Roots ?? []; + return $"Found {roots.Count} roots: {string.Join(", ", roots.Select(r => r.Uri))}"; + } + + if (!server.IsMrtrSupported) + { + return "This tool requires MRTR support."; + } + + // First call — request the client's root list + throw new IncompleteResultException( + inputRequests: new Dictionary + { + ["get_roots"] = InputRequest.ForRootsList(new ListRootsRequestParams()) + }, + requestState: "awaiting-roots"); +} +``` + +> [!TIP] +> See [Multi Round-Trip Requests (MRTR)](xref:mrtr) for the full protocol details, including load shedding, multiple round trips, and the compatibility matrix. diff --git a/docs/concepts/sampling/sampling.md b/docs/concepts/sampling/sampling.md index 4f14a4ee0..03397ddca 100644 --- a/docs/concepts/sampling/sampling.md +++ b/docs/concepts/sampling/sampling.md @@ -120,3 +120,76 @@ McpClientOptions options = new() ### Capability negotiation Sampling requires the client to advertise the `sampling` capability. This is handled automatically — when a is set, the client includes the sampling capability during initialization. The server can check whether the client supports sampling before calling ; if sampling is not supported, the method throws . + +### Multi Round-Trip Requests (MRTR) + +When both the client and server opt in to the experimental [MRTR](xref:mrtr) protocol, sampling requests are handled via incomplete result / retry instead of a direct JSON-RPC request. This is transparent — the existing `SampleAsync` and `AsSamplingChatClient` APIs work identically regardless of whether MRTR is active. + +#### High-level API + +No code changes are needed. `SampleAsync` and `AsSamplingChatClient` automatically use MRTR when both sides have opted in, and fall back to legacy JSON-RPC requests otherwise: + +```csharp +// This code works the same with or without MRTR — the SDK handles it transparently. +var result = await server.SampleAsync( + new CreateMessageRequestParams + { + Messages = + [ + new SamplingMessage + { + Role = Role.User, + Content = [new TextContentBlock { Text = "Summarize the data" }] + } + ], + MaxTokens = 256, + }, + cancellationToken); +``` + +#### Low-level API + +For stateless servers or scenarios requiring manual control, throw with a sampling input request. On retry, read the client's response from : + +```csharp +[McpServerTool, Description("Tool that samples via low-level MRTR")] +public static string SampleWithMrtr( + McpServer server, + RequestContext context) +{ + // On retry, process the client's sampling response + if (context.Params!.InputResponses?.TryGetValue("llm_call", out var response) is true) + { + var text = response.SamplingResult?.Content + .OfType().FirstOrDefault()?.Text; + return $"LLM said: {text}"; + } + + if (!server.IsMrtrSupported) + { + return "This tool requires MRTR support."; + } + + // First call — request LLM completion from the client + throw new IncompleteResultException( + inputRequests: new Dictionary + { + ["llm_call"] = InputRequest.ForSampling(new CreateMessageRequestParams + { + Messages = + [ + new SamplingMessage + { + Role = Role.User, + Content = [new TextContentBlock { Text = "Summarize the data" }] + } + ], + MaxTokens = 256 + }) + }, + requestState: "awaiting-sample"); +} +``` + +> [!TIP] +> See [Multi Round-Trip Requests (MRTR)](xref:mrtr) for the full protocol details, including load shedding, multiple round trips, and the compatibility matrix. diff --git a/docs/concepts/tasks/tasks.md b/docs/concepts/tasks/tasks.md index 91f86db1a..e5294f513 100644 --- a/docs/concepts/tasks/tasks.md +++ b/docs/concepts/tasks/tasks.md @@ -137,6 +137,58 @@ Task support levels: - `Optional` (default for async methods): Tool can be called with or without task augmentation - `Required`: Tool must be called with task augmentation +### Deferred Task Creation with MRTR + + +> [!WARNING] +> Deferred task creation depends on both the [Tasks](xref:tasks) and [MRTR](xref:mrtr) experimental features. + +By default, when a client sends task metadata with a `tools/call` request, the SDK creates a task immediately and runs the tool in the background. **Deferred task creation** delays the task creation, letting the tool perform ephemeral [MRTR](xref:mrtr) exchanges first — for example, to confirm an action with the user or gather required parameters — before committing to a background task. + +To opt in, set `DeferTaskCreation = true` on the tool: + +```csharp +McpServerTool.Create( + async (string vmName, McpServer server, CancellationToken ct) => + { + // Ephemeral MRTR — uses incomplete result / retry cycle. + var confirmation = await server.ElicitAsync(new ElicitRequestParams + { + Message = $"Provision VM '{vmName}'? This will incur costs.", + RequestedSchema = new() + }, ct); + + if (confirmation.Action != "confirm") + { + return "Cancelled by user."; + } + + // Transition to a background task. + await server.CreateTaskAsync(ct); + + // Background work — runs as a task, client polls for status. + await Task.Delay(TimeSpan.FromMinutes(5), ct); + return $"VM '{vmName}' provisioned successfully."; + }, + new McpServerToolCreateOptions + { + Name = "provision-vm", + Description = "Provisions a VM with user confirmation", + DeferTaskCreation = true, + Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional }, + }) +``` + +After returns: + +- The MRTR phase ends. The client receives a `CreateTaskResult` with the `taskId`. +- Any subsequent `ElicitAsync` or `SampleAsync` calls in the handler use the task's `input_required` / `tasks/input_response` workflow instead of MRTR. +- The handler's cancellation token is re-linked to the task's lifecycle (TTL expiration, explicit `tasks/cancel`). + +If the tool returns without calling `CreateTaskAsync`, a normal (non-task) result is sent to the client — no task is created. + +For more details on the MRTR mechanism and the transition flow, see [Transitioning from MRTR to Tasks](xref:mrtr#transitioning-from-mrtr-to-tasks). + ### Explicit Task Creation with `IMcpTaskStore` For more control over task lifecycle, tools can directly interact with and return an `McpTask`. This approach allows you to: diff --git a/docs/concepts/toc.yml b/docs/concepts/toc.yml index bd5474338..e0708cb75 100644 --- a/docs/concepts/toc.yml +++ b/docs/concepts/toc.yml @@ -19,6 +19,8 @@ items: uid: cancellation - name: Tasks uid: tasks + - name: Multi Round-Trip Requests (MRTR) + uid: mrtr - name: Client Features items: - name: Sampling diff --git a/src/Common/Experimentals.cs b/src/Common/Experimentals.cs index 7e7e969bb..e356480ed 100644 --- a/src/Common/Experimentals.cs +++ b/src/Common/Experimentals.cs @@ -110,4 +110,23 @@ internal static class Experimentals /// URL for the experimental RunSessionHandler API. /// public const string RunSessionHandler_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcpexp002"; + + /// + /// Diagnostic ID for the experimental Multi Round-Trip Requests (MRTR) feature. + /// + /// + /// This uses the same diagnostic ID as because MRTR + /// is an experimental feature in the MCP specification (SEP-2322). + /// + public const string Mrtr_DiagnosticId = "MCPEXP001"; + + /// + /// Message for the experimental MRTR feature. + /// + public const string Mrtr_Message = "The Multi Round-Trip Requests (MRTR) feature is experimental per the MCP specification (SEP-2322) and is subject to change."; + + /// + /// URL for the experimental MRTR feature. + /// + public const string Mrtr_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcpexp001"; } diff --git a/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj b/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj index 762091667..44bac1bc9 100644 --- a/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj +++ b/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj @@ -10,6 +10,7 @@ ASP.NET Core extensions for the C# Model Context Protocol (MCP) SDK. README.md true + $(NoWarn);MCPEXP001 diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 49922b8d9..b326018ad 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -495,12 +495,25 @@ internal static string MakeNewSessionId() // Implementation for reading a JSON-RPC message from the request body var message = await context.Request.ReadFromJsonAsync(s_messageTypeInfo, context.RequestAborted); - if (context.User?.Identity?.IsAuthenticated == true && message is not null) + if (message is not null) { - message.Context = new() + var protocolVersion = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); + var isAuthenticated = context.User?.Identity?.IsAuthenticated == true; + + if (isAuthenticated || !string.IsNullOrEmpty(protocolVersion)) { - User = context.User, - }; + message.Context ??= new(); + + if (isAuthenticated) + { + message.Context.User = context.User; + } + + if (!string.IsNullOrEmpty(protocolVersion)) + { + message.Context.ProtocolVersion = protocolVersion; + } + } } return message; @@ -535,11 +548,12 @@ internal static Task RunSessionAsync(HttpContext httpContext, McpServer session, /// Validates the MCP-Protocol-Version header if present. A missing header is allowed for backwards compatibility, /// but an invalid or unsupported value must be rejected with 400 Bad Request per the MCP spec. /// - private static bool ValidateProtocolVersionHeader(HttpContext context, out string? errorMessage) + private bool ValidateProtocolVersionHeader(HttpContext context, out string? errorMessage) { var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); if (!string.IsNullOrEmpty(protocolVersionHeader) && - !s_supportedProtocolVersions.Contains(protocolVersionHeader)) + !s_supportedProtocolVersions.Contains(protocolVersionHeader) && + !(mcpServerOptionsSnapshot.Value.ExperimentalProtocolVersion is { } experimentalVersion && protocolVersionHeader == experimentalVersion)) { errorMessage = $"Bad Request: The MCP-Protocol-Version header value '{protocolVersionHeader}' is not supported."; return false; diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index 66410b272..521fd51ee 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -3,6 +3,7 @@ using ModelContextProtocol.Protocol; using System.Collections.Concurrent; using System.Text.Json; +using System.Text.Json.Nodes; namespace ModelContextProtocol.Client; @@ -142,6 +143,8 @@ private void RegisterHandlers(McpClientOptions options, NotificationHandlers not RequestMethods.SamplingCreateMessage, async (request, jsonRpcRequest, cancellationToken) => { + WarnIfLegacyRequestOnMrtrSession(RequestMethods.SamplingCreateMessage); + // Check if this is a task-augmented request if (request?.Task is { } taskMetadata) { @@ -176,10 +179,14 @@ private void RegisterHandlers(McpClientOptions options, NotificationHandlers not { requestHandlers.Set( RequestMethods.SamplingCreateMessage, - (request, _, cancellationToken) => samplingHandler( - request, - request?.ProgressToken is { } token ? new TokenProgress(this, token) : NullProgress.Instance, - cancellationToken), + (request, _, cancellationToken) => + { + WarnIfLegacyRequestOnMrtrSession(RequestMethods.SamplingCreateMessage); + return samplingHandler( + request, + request?.ProgressToken is { } token ? new TokenProgress(this, token) : NullProgress.Instance, + cancellationToken); + }, McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams, McpJsonUtilities.JsonContext.Default.CreateMessageResult); } @@ -192,7 +199,11 @@ private void RegisterHandlers(McpClientOptions options, NotificationHandlers not { requestHandlers.Set( RequestMethods.RootsList, - (request, _, cancellationToken) => rootsHandler(request, cancellationToken), + (request, _, cancellationToken) => + { + WarnIfLegacyRequestOnMrtrSession(RequestMethods.RootsList); + return rootsHandler(request, cancellationToken); + }, McpJsonUtilities.JsonContext.Default.ListRootsRequestParams, McpJsonUtilities.JsonContext.Default.ListRootsResult); @@ -209,6 +220,8 @@ private void RegisterHandlers(McpClientOptions options, NotificationHandlers not RequestMethods.ElicitationCreate, async (request, jsonRpcRequest, cancellationToken) => { + WarnIfLegacyRequestOnMrtrSession(RequestMethods.ElicitationCreate); + // Check if this is a task-augmented request if (request?.Task is { } taskMetadata) { @@ -241,6 +254,7 @@ private void RegisterHandlers(McpClientOptions options, NotificationHandlers not RequestMethods.ElicitationCreate, async (request, _, cancellationToken) => { + WarnIfLegacyRequestOnMrtrSession(RequestMethods.ElicitationCreate); var result = await elicitationHandler(request, cancellationToken).ConfigureAwait(false); return ElicitResult.WithDefaults(request, result); }, @@ -547,6 +561,77 @@ private void RegisterTaskHandlers(RequestHandlers requestHandlers, IMcpTaskStore /// public override Task Completion => _sessionHandler.CompletionTask; + /// + private async ValueTask> ResolveInputRequestsAsync( + IDictionary inputRequests, + CancellationToken cancellationToken) + { + var responses = new Dictionary(inputRequests.Count); + + // Resolve all input requests concurrently + var tasks = new List<(string Key, Task Task)>(inputRequests.Count); + foreach (var kvp in inputRequests) + { + tasks.Add((kvp.Key, ResolveInputRequestAsync(kvp.Value, cancellationToken))); + } + + foreach (var entry in tasks) + { + responses[entry.Key] = await entry.Task.ConfigureAwait(false); + } + + return responses; + } + + private async Task ResolveInputRequestAsync(InputRequest inputRequest, CancellationToken cancellationToken) + { + switch (inputRequest.Method) + { + case RequestMethods.SamplingCreateMessage: + if (_options.Handlers.SamplingHandler is { } samplingHandler) + { + var samplingParams = inputRequest.SamplingParams + ?? throw new McpException($"Failed to deserialize sampling parameters from MRTR input request."); + var result = await samplingHandler( + samplingParams, + samplingParams.ProgressToken is { } token ? new TokenProgress(this, token) : NullProgress.Instance, + cancellationToken).ConfigureAwait(false); + return InputResponse.FromSamplingResult(result); + } + + throw new InvalidOperationException( + $"Server sent a sampling input request, but no {nameof(McpClientHandlers.SamplingHandler)} is registered."); + + case RequestMethods.ElicitationCreate: + if (_options.Handlers.ElicitationHandler is { } elicitationHandler) + { + var elicitParams = inputRequest.ElicitationParams + ?? throw new McpException($"Failed to deserialize elicitation parameters from MRTR input request."); + var result = await elicitationHandler(elicitParams, cancellationToken).ConfigureAwait(false); + result = ElicitResult.WithDefaults(elicitParams, result); + return InputResponse.FromElicitResult(result); + } + + throw new InvalidOperationException( + $"Server sent an elicitation input request, but no {nameof(McpClientHandlers.ElicitationHandler)} is registered."); + + case RequestMethods.RootsList: + if (_options.Handlers.RootsHandler is { } rootsHandler) + { + var rootsParams = inputRequest.RootsParams + ?? throw new McpException($"Failed to deserialize roots parameters from MRTR input request."); + var result = await rootsHandler(rootsParams, cancellationToken).ConfigureAwait(false); + return InputResponse.FromRootsResult(result); + } + + throw new InvalidOperationException( + $"Server sent a roots list input request, but no {nameof(McpClientHandlers.RootsHandler)} is registered."); + + default: + throw new NotSupportedException($"Unsupported input request method: '{inputRequest.Method}'."); + } + } + /// /// Asynchronously connects to an MCP server, establishes the transport connection, and completes the initialization handshake. /// @@ -565,7 +650,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) try { // Send initialize request - string requestProtocol = _options.ProtocolVersion ?? McpSessionHandler.LatestProtocolVersion; + string requestProtocol = _options.ProtocolVersion ?? _options.ExperimentalProtocolVersion ?? McpSessionHandler.LatestProtocolVersion; var initializeResponse = await SendRequestAsync( RequestMethods.Initialize, new InitializeRequestParams @@ -593,7 +678,8 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) // Validate protocol version bool isResponseProtocolValid = _options.ProtocolVersion is { } optionsProtocol ? optionsProtocol == initializeResponse.ProtocolVersion : - McpSessionHandler.SupportedProtocolVersions.Contains(initializeResponse.ProtocolVersion); + McpSessionHandler.SupportedProtocolVersions.Contains(initializeResponse.ProtocolVersion) || + (_options.ExperimentalProtocolVersion is not null && _options.ExperimentalProtocolVersion == initializeResponse.ProtocolVersion); if (!isResponseProtocolValid) { LogServerProtocolVersionMismatch(_endpointName, requestProtocol, initializeResponse.ProtocolVersion); @@ -654,6 +740,7 @@ internal void ResumeSession(ResumeClientSessionOptions resumeOptions) LogClientSessionResumed(_endpointName); } + /// /// public override void AddKnownTools(IEnumerable tools) { @@ -718,13 +805,13 @@ public override void ClearKnownTools() } /// - public override Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) + public override async Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) { // For tools/call requests, attach the cached tool definition to the message context // so the transport can add custom Mcp-Param-* headers based on x-mcp-header schema annotations. if (request.Method == RequestMethods.ToolsCall && - request.Params is System.Text.Json.Nodes.JsonObject paramsObj && - paramsObj.TryGetPropertyValue("name", out var nameNode) && + request.Params is System.Text.Json.Nodes.JsonObject paramsObjForHeaders && + paramsObjForHeaders.TryGetPropertyValue("name", out var nameNode) && nameNode?.GetValue() is { } toolName) { if (_toolCache.TryGetValue(toolName, out var tool)) @@ -739,7 +826,61 @@ request.Params is System.Text.Json.Nodes.JsonObject paramsObj && } } - return _sessionHandler.SendRequestAsync(request, cancellationToken); + const int maxRetries = 10; + + for (int attempt = 0; attempt <= maxRetries; attempt++) + { + JsonRpcResponse response = await _sessionHandler.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); + + // Check if the result is an IncompleteResult by looking at result_type. + if (response.Result is JsonObject resultObj && + resultObj.TryGetPropertyValue("result_type", out var resultTypeNode) && + resultTypeNode?.GetValue() is "incomplete") + { + WarnIfIncompleteResultOnNonMrtrSession(request.Method); + + var incompleteResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.JsonContext.Default.IncompleteResult) + ?? throw new JsonException("Failed to deserialize IncompleteResult."); + + if (incompleteResult.InputRequests is { Count: > 0 } inputRequests) + { + IDictionary inputResponses = + await ResolveInputRequestsAsync(inputRequests, cancellationToken).ConfigureAwait(false); + + // Clone the original request params and add inputResponses + requestState for the retry. + var paramsObj = request.Params?.DeepClone() as JsonObject ?? new JsonObject(); + + paramsObj["inputResponses"] = JsonSerializer.SerializeToNode( + inputResponses, McpJsonUtilities.JsonContext.Default.IDictionaryStringInputResponse); + + if (incompleteResult.RequestState is { } requestState) + { + paramsObj["requestState"] = requestState; + } + + request = new JsonRpcRequest { Method = request.Method, Params = paramsObj, Context = request.Context }; + } + else if (incompleteResult.RequestState is not null) + { + // No input requests but has requestState (e.g., load shedding) — just retry with state. + var paramsObj = request.Params?.DeepClone() as JsonObject ?? new JsonObject(); + paramsObj["requestState"] = incompleteResult.RequestState; + paramsObj.Remove("inputResponses"); + + request = new JsonRpcRequest { Method = request.Method, Params = paramsObj, Context = request.Context }; + } + else + { + throw new McpException("Server returned an IncompleteResult without inputRequests or requestState."); + } + + continue; // retry with the updated request + } + + return response; + } + + throw new McpException($"Server returned IncompleteResult more than {maxRetries} times."); } /// @@ -775,6 +916,32 @@ public override async ValueTask DisposeAsync() await Completion.ConfigureAwait(false); } + /// Logs a warning if the session negotiated MRTR but the server sent a legacy JSON-RPC request. + private void WarnIfLegacyRequestOnMrtrSession(string method) + { + if (_options.ExperimentalProtocolVersion is not null && + _negotiatedProtocolVersion == _options.ExperimentalProtocolVersion) + { + LogLegacyRequestOnMrtrSession(_endpointName, method); + } + } + + /// Logs a warning if the session did not negotiate MRTR but the server sent an IncompleteResult. + private void WarnIfIncompleteResultOnNonMrtrSession(string method) + { + if (_options.ExperimentalProtocolVersion is null || + _negotiatedProtocolVersion != _options.ExperimentalProtocolVersion) + { + LogIncompleteResultOnNonMrtrSession(_endpointName, method, _negotiatedProtocolVersion); + } + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} received legacy '{Method}' JSON-RPC request on session that negotiated MRTR. The server should use IncompleteResult instead of sending direct requests.")] + private partial void LogLegacyRequestOnMrtrSession(string endpointName, string method); + + [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} received IncompleteResult for '{Method}' on session that did not negotiate MRTR (protocol version '{ProtocolVersion}'). The server may not be spec-compliant.")] + private partial void LogIncompleteResultOnNonMrtrSession(string endpointName, string method, string? protocolVersion); + [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} client received server '{ServerInfo}' capabilities: '{Capabilities}'.")] private partial void LogServerCapabilitiesReceived(string endpointName, string capabilities, string serverInfo); @@ -798,5 +965,4 @@ public override async ValueTask DisposeAsync() [LoggerMessage(Level = LogLevel.Warning, Message = "Tool '{ToolName}' excluded from tools/list: {Reason}")] private partial void LogToolRejected(string toolName, string reason); - } diff --git a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs index 6d91f5b03..3c088fdb3 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs @@ -111,4 +111,24 @@ public McpClientHandlers Handlers /// [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] public bool SendTaskStatusNotifications { get; set; } = true; + + /// + /// Gets or sets an experimental protocol version that enables draft protocol features such as + /// Multi Round-Trip Requests (MRTR). + /// + /// + /// + /// When set, this version is used as the requested protocol version during initialization instead of + /// the latest stable version. The server must also have a matching ExperimentalProtocolVersion + /// configured for the experimental features to activate. If the server does not recognize the + /// experimental version, it will negotiate to the latest stable version and the client will work + /// normally without experimental features. + /// + /// + /// This property is intended for proof-of-concept and testing of draft MCP specification features + /// that have not yet been ratified. + /// + /// + [Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] + public string? ExperimentalProtocolVersion { get; set; } } diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index abb6d29df..daf738062 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -144,6 +144,13 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSerializable(typeof(SubscribeRequestParams))] [JsonSerializable(typeof(UnsubscribeRequestParams))] + // MCP MRTR (Multi Round-Trip Requests) + [JsonSerializable(typeof(IncompleteResult))] + [JsonSerializable(typeof(InputRequest))] + [JsonSerializable(typeof(InputResponse))] + [JsonSerializable(typeof(IDictionary))] + [JsonSerializable(typeof(IDictionary))] + // MCP Task Request Params / Results [JsonSerializable(typeof(McpTask))] [JsonSerializable(typeof(McpTaskStatus))] diff --git a/src/ModelContextProtocol.Core/McpSession.cs b/src/ModelContextProtocol.Core/McpSession.cs index 4201f9833..73d99da71 100644 --- a/src/ModelContextProtocol.Core/McpSession.cs +++ b/src/ModelContextProtocol.Core/McpSession.cs @@ -68,7 +68,7 @@ public abstract partial class McpSession : IAsyncDisposable /// /// The to monitor for cancellation requests. The default is . /// A task that represents the asynchronous send operation. - /// The transport is not connected. + /// The transport is not connected, or is a . Use for requests. /// is . /// /// diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index 77c18b8be..6ee7a4f11 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -31,6 +31,14 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable /// The latest version of the protocol supported by this implementation. internal const string LatestProtocolVersion = "2025-11-25"; + /// + /// The experimental protocol version that enables MRTR (Multi Round-Trip Requests). + /// This version is not in and is only accepted + /// when or + /// is set to this value. + /// + internal const string ExperimentalProtocolVersion = "2026-06-XX"; + /// /// All protocol versions supported by this implementation. /// Keep in sync with s_supportedProtocolVersions in StreamableHttpHandler. @@ -584,7 +592,16 @@ public async Task SendRequestAsync(JsonRpcRequest request, Canc AddTags(ref tags, activity, request, method, target); } - await SendToRelatedTransportAsync(request, cancellationToken).ConfigureAwait(false); + if (_logger.IsEnabled(LogLevel.Trace)) + { + LogSendingRequestSensitive(EndpointName, request.Method, JsonSerializer.Serialize(request, McpJsonUtilities.JsonContext.Default.JsonRpcMessage)); + } + else + { + LogSendingRequest(EndpointName, request.Method); + } + + await _outgoingMessageFilter(SendToRelatedTransportAsync)(request, cancellationToken).ConfigureAwait(false); // Now that the request has been sent, register for cancellation. If we registered before, // a cancellation request could arrive before the server knew about that request ID, in which @@ -642,6 +659,13 @@ public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken can { Throw.IfNull(message); + if (message is JsonRpcRequest request) + { + throw new InvalidOperationException( + $"Cannot send '{request.Method}' request via {nameof(SendMessageAsync)}. " + + $"Use {nameof(SendRequestAsync)} instead to get a correlated response."); + } + cancellationToken.ThrowIfCancellationRequested(); Histogram durationMetric = _isServer ? s_serverOperationDuration : s_clientOperationDuration; diff --git a/src/ModelContextProtocol.Core/Protocol/IncompleteResult.cs b/src/ModelContextProtocol.Core/Protocol/IncompleteResult.cs new file mode 100644 index 000000000..a54a06dd0 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/IncompleteResult.cs @@ -0,0 +1,63 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents an incomplete result sent by the server to indicate that additional input is needed +/// before the request can be completed. +/// +/// +/// +/// An is returned in response to a client-initiated request (such as +/// or ) when the server +/// needs the client to fulfill one or more server-initiated requests before it can produce a final result. +/// +/// +/// At least one of or must be present. +/// +/// +/// This type is part of the Multi Round-Trip Requests (MRTR) mechanism defined in SEP-2322. +/// +/// +[Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] +public sealed class IncompleteResult : Result +{ + /// + /// Initializes a new instance of the class. + /// + public IncompleteResult() + { + ResultType = "incomplete"; + } + + /// + /// Gets or sets the server-initiated requests that the client must fulfill before retrying the original request. + /// + /// + /// + /// The keys are server-assigned identifiers. The client must include a response for each key in the + /// map when retrying the original request. + /// + /// + [JsonPropertyName("inputRequests")] + public IDictionary? InputRequests { get; set; } + + /// + /// Gets or sets opaque state to be echoed back by the client when retrying the original request. + /// + /// + /// + /// The client must treat this as an opaque blob and must not inspect, parse, modify, or make + /// any assumptions about the contents. If present, the client must include this value in the + /// property when retrying the original request. + /// + /// + /// Servers may encode request state in any format (e.g., plain JSON, base64-encoded JSON, + /// encrypted JWT, serialized binary). If the state contains sensitive data, servers should + /// encrypt it to ensure confidentiality and integrity. + /// + /// + [JsonPropertyName("requestState")] + public string? RequestState { get; set; } +} diff --git a/src/ModelContextProtocol.Core/Protocol/IncompleteResultException.cs b/src/ModelContextProtocol.Core/Protocol/IncompleteResultException.cs new file mode 100644 index 000000000..8ee439e4a --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/IncompleteResultException.cs @@ -0,0 +1,109 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ModelContextProtocol.Protocol; + +/// +/// The exception that is thrown by a server handler to return an +/// to the client, signaling that additional input is needed before the request can be completed. +/// +/// +/// +/// This exception is part of the low-level Multi Round-Trip Requests (MRTR) API. Tool handlers +/// throw this exception to directly control the incomplete result payload, including +/// and . +/// +/// +/// For stateless servers, this enables multi-round-trip flows without requiring the handler to stay +/// alive between round trips. The server encodes its state in +/// and receives it back on retry via . +/// +/// +/// To return a requestState-only response (e.g., for load shedding), omit +/// and set only . +/// The client will retry the request with the state echoed back. +/// +/// +/// This exception can only be used when MRTR is supported by the client. Check +/// before throwing. If thrown when MRTR is not +/// supported, the exception will propagate as a JSON-RPC internal error. +/// +/// +/// +/// +/// [McpServerTool, Description("A stateless tool using low-level MRTR")] +/// public static string MyTool(McpServer server, RequestContext<CallToolRequestParams> context) +/// { +/// if (context.Params.RequestState is { } state) +/// { +/// // Retry: process accumulated state and input responses +/// var responses = context.Params.InputResponses; +/// return "Final result"; +/// } +/// +/// if (!server.IsMrtrSupported) +/// { +/// return "This tool requires MRTR support."; +/// } +/// +/// throw new IncompleteResultException( +/// inputRequests: new Dictionary<string, InputRequest> +/// { +/// ["user_input"] = InputRequest.ForElicitation(new ElicitRequestParams { ... }) +/// }, +/// requestState: "encoded-state"); +/// } +/// +/// +[Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] +public class IncompleteResultException : Exception +{ + /// + /// Initializes a new instance of the class + /// with the specified . + /// + /// The incomplete result to return to the client. + public IncompleteResultException(IncompleteResult incompleteResult) + : base("The server returned an incomplete result requiring additional client input.") + { + Throw.IfNull(incompleteResult); + IncompleteResult = incompleteResult; + } + + /// + /// Initializes a new instance of the class + /// with the specified input requests and/or request state. + /// + /// + /// Server-initiated requests that the client must fulfill before retrying. + /// Keys are server-assigned identifiers. + /// + /// + /// Opaque state to be echoed back by the client when retrying. The client must + /// treat this as an opaque blob and must not inspect or modify it. + /// + /// + /// Both and are . + /// At least one must be provided. + /// + public IncompleteResultException( + IDictionary? inputRequests = null, + string? requestState = null) + : base("The server returned an incomplete result requiring additional client input.") + { + if (inputRequests is null && requestState is null) + { + throw new ArgumentException("At least one of inputRequests or requestState must be provided."); + } + + IncompleteResult = new IncompleteResult + { + InputRequests = inputRequests, + RequestState = requestState, + }; + } + + /// + /// Gets the incomplete result to return to the client. + /// + public IncompleteResult IncompleteResult { get; } +} diff --git a/src/ModelContextProtocol.Core/Protocol/InputRequest.cs b/src/ModelContextProtocol.Core/Protocol/InputRequest.cs new file mode 100644 index 000000000..e87551427 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/InputRequest.cs @@ -0,0 +1,197 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents a server-initiated request that the client must fulfill as part of an MRTR +/// (Multi Round-Trip Request) flow. +/// +/// +/// +/// An wraps a server-to-client request such as +/// , , +/// or . It is included in an +/// when the server needs additional input before it can complete a client-initiated request. +/// +/// +/// The property identifies the type of request, and the corresponding +/// parameters can be accessed via the typed accessor properties. +/// +/// +[Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] +[JsonConverter(typeof(Converter))] +public sealed class InputRequest +{ + /// + /// Gets or sets the method name identifying the type of this input request. + /// + /// + /// Standard values include: + /// + /// A sampling request. + /// An elicitation request. + /// A roots list request. + /// + /// + [JsonPropertyName("method")] + public required string Method { get; set; } + + /// + /// Gets or sets the raw JSON parameters for this input request. + /// + /// + /// Use the typed accessor properties (, , + /// ) for convenient strongly-typed access. + /// + [JsonPropertyName("params")] + public JsonElement? Params { get; set; } + + /// + /// Gets the parameters as when + /// is . + /// + /// The deserialized sampling parameters, or if the method does not match or params are absent. + [JsonIgnore] + public CreateMessageRequestParams? SamplingParams => + string.Equals(Method, RequestMethods.SamplingCreateMessage, StringComparison.Ordinal) && Params is { } p + ? JsonSerializer.Deserialize(p, McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams) + : null; + + /// + /// Gets the parameters as when + /// is . + /// + /// The deserialized elicitation parameters, or if the method does not match or params are absent. + [JsonIgnore] + public ElicitRequestParams? ElicitationParams => + string.Equals(Method, RequestMethods.ElicitationCreate, StringComparison.Ordinal) && Params is { } p + ? JsonSerializer.Deserialize(p, McpJsonUtilities.JsonContext.Default.ElicitRequestParams) + : null; + + /// + /// Gets the parameters as when + /// is . + /// + /// The deserialized roots list parameters, or if the method does not match or params are absent. + [JsonIgnore] + public ListRootsRequestParams? RootsParams => + string.Equals(Method, RequestMethods.RootsList, StringComparison.Ordinal) && Params is { } p + ? JsonSerializer.Deserialize(p, McpJsonUtilities.JsonContext.Default.ListRootsRequestParams) + : null; + + /// + /// Creates an for a sampling request. + /// + /// The sampling request parameters. + /// A new instance. + public static InputRequest ForSampling(CreateMessageRequestParams requestParams) + { + Throw.IfNull(requestParams); + return new() + { + Method = RequestMethods.SamplingCreateMessage, + Params = JsonSerializer.SerializeToElement(requestParams, McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams), + }; + } + + /// + /// Creates an for an elicitation request. + /// + /// The elicitation request parameters. + /// A new instance. + public static InputRequest ForElicitation(ElicitRequestParams requestParams) + { + Throw.IfNull(requestParams); + return new() + { + Method = RequestMethods.ElicitationCreate, + Params = JsonSerializer.SerializeToElement(requestParams, McpJsonUtilities.JsonContext.Default.ElicitRequestParams), + }; + } + + /// + /// Creates an for a roots list request. + /// + /// The roots list request parameters. + /// A new instance. + public static InputRequest ForRootsList(ListRootsRequestParams requestParams) + { + Throw.IfNull(requestParams); + return new() + { + Method = RequestMethods.RootsList, + Params = JsonSerializer.SerializeToElement(requestParams, McpJsonUtilities.JsonContext.Default.ListRootsRequestParams), + }; + } + + /// Provides JSON serialization support for . + public sealed class Converter : JsonConverter + { + /// + public override InputRequest? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Expected StartObject token."); + } + + string? method = null; + JsonElement? parameters = null; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected PropertyName token."); + } + + string propertyName = reader.GetString()!; + reader.Read(); + + switch (propertyName) + { + case "method": + method = reader.GetString(); + break; + case "params": + parameters = JsonElement.ParseValue(ref reader); + break; + default: + reader.Skip(); + break; + } + } + + if (method is null) + { + throw new JsonException("InputRequest must have a 'method' property."); + } + + return new InputRequest + { + Method = method, + Params = parameters, + }; + } + + /// + public override void Write(Utf8JsonWriter writer, InputRequest value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteString("method", value.Method); + if (value.Params is { } p) + { + writer.WritePropertyName("params"); + p.WriteTo(writer); + } + writer.WriteEndObject(); + } + } +} diff --git a/src/ModelContextProtocol.Core/Protocol/InputResponse.cs b/src/ModelContextProtocol.Core/Protocol/InputResponse.cs new file mode 100644 index 000000000..b9e99002e --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/InputResponse.cs @@ -0,0 +1,127 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents a client's response to a server-initiated as part of an MRTR +/// (Multi Round-Trip Request) flow. +/// +/// +/// +/// An wraps the result of a server-to-client request such as +/// , , or . +/// The type of the inner response corresponds to the of the +/// associated input request. +/// +/// +/// The input response does not carry its own type discriminator in JSON. The type is determined by +/// the corresponding key in the map. +/// +/// +[Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] +[JsonConverter(typeof(Converter))] +public sealed class InputResponse +{ + /// + /// Gets or sets the raw JSON element representing the response. + /// + /// + /// Use or the typed factory methods to work with concrete response types. + /// + [JsonIgnore] + public JsonElement RawValue { get; set; } + + /// + /// Deserializes the raw value to the specified result type. + /// + /// The type to deserialize to (e.g., , ). + /// The JSON type information for . + /// The deserialized result, or if deserialization fails. + public T? Deserialize(System.Text.Json.Serialization.Metadata.JsonTypeInfo typeInfo) => + JsonSerializer.Deserialize(RawValue, typeInfo); + + /// + /// Gets the response as a . + /// + /// The deserialized sampling result, or if deserialization fails. + [JsonIgnore] + public CreateMessageResult? SamplingResult => + JsonSerializer.Deserialize(RawValue, McpJsonUtilities.JsonContext.Default.CreateMessageResult); + + /// + /// Gets the response as an . + /// + /// The deserialized elicitation result, or if deserialization fails. + [JsonIgnore] + public ElicitResult? ElicitationResult => + JsonSerializer.Deserialize(RawValue, McpJsonUtilities.JsonContext.Default.ElicitResult); + + /// + /// Gets the response as a . + /// + /// The deserialized roots list result, or if deserialization fails. + [JsonIgnore] + public ListRootsResult? RootsResult => + JsonSerializer.Deserialize(RawValue, McpJsonUtilities.JsonContext.Default.ListRootsResult); + + /// + /// Creates an from a . + /// + /// The sampling result. + /// A new instance. + public static InputResponse FromSamplingResult(CreateMessageResult result) + { + Throw.IfNull(result); + return new() + { + RawValue = JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.CreateMessageResult), + }; + } + + /// + /// Creates an from an . + /// + /// The elicitation result. + /// A new instance. + public static InputResponse FromElicitResult(ElicitResult result) + { + Throw.IfNull(result); + return new() + { + RawValue = JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.ElicitResult), + }; + } + + /// + /// Creates an from a . + /// + /// The roots list result. + /// A new instance. + public static InputResponse FromRootsResult(ListRootsResult result) + { + Throw.IfNull(result); + return new() + { + RawValue = JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.ListRootsResult), + }; + } + + /// Provides JSON serialization support for . + public sealed class Converter : JsonConverter + { + /// + public override InputResponse? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var element = JsonElement.ParseValue(ref reader); + return new InputResponse { RawValue = element }; + } + + /// + public override void Write(Utf8JsonWriter writer, InputResponse value, JsonSerializerOptions options) + { + value.RawValue.WriteTo(writer); + } + } +} diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs index 2fa9839f0..e5c0f3931 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs @@ -74,4 +74,15 @@ public sealed class JsonRpcMessageContext /// /// public IDictionary? Items { get; set; } + + /// + /// Gets or sets the protocol version from the transport-level header (e.g. Mcp-Protocol-Version) + /// that accompanied this JSON-RPC message. + /// + /// + /// In stateless Streamable HTTP mode, the protocol version cannot be negotiated via the initialize + /// handshake because each request creates a new server instance. This property allows the transport layer + /// to flow the protocol version header so the server can determine client capabilities. + /// + public string? ProtocolVersion { get; set; } } diff --git a/src/ModelContextProtocol.Core/Protocol/RequestParams.cs b/src/ModelContextProtocol.Core/Protocol/RequestParams.cs index 0a0586a71..4ba1a7093 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestParams.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Nodes; using System.Text.Json.Serialization; @@ -25,6 +26,52 @@ private protected RequestParams() [JsonPropertyName("_meta")] public JsonObject? Meta { get; set; } + /// + /// Gets or sets the responses to server-initiated input requests from a previous . + /// + /// + /// + /// This property is populated when retrying a request after receiving an . + /// Each key corresponds to a key from the map, and + /// the value is the client's response to that input request. + /// + /// + [Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] + [JsonIgnore] + public IDictionary? InputResponses + { + get => InputResponsesCore; + set => InputResponsesCore = value; + } + + // See ExperimentalInternalPropertyTests.cs before modifying this property. + [JsonInclude] + [JsonPropertyName("inputResponses")] + internal IDictionary? InputResponsesCore { get; set; } + + /// + /// Gets or sets opaque request state echoed back from a previous . + /// + /// + /// + /// This property is populated when retrying a request after receiving an + /// that included a value. The client must echo back the + /// exact value without modification. + /// + /// + [Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] + [JsonIgnore] + public string? RequestState + { + get => RequestStateCore; + set => RequestStateCore = value; + } + + // See ExperimentalInternalPropertyTests.cs before modifying this property. + [JsonInclude] + [JsonPropertyName("requestState")] + internal string? RequestStateCore { get; set; } + /// /// Gets the opaque token that will be attached to any subsequent progress notifications. /// diff --git a/src/ModelContextProtocol.Core/Protocol/Result.cs b/src/ModelContextProtocol.Core/Protocol/Result.cs index 58b076ddb..9b4531414 100644 --- a/src/ModelContextProtocol.Core/Protocol/Result.cs +++ b/src/ModelContextProtocol.Core/Protocol/Result.cs @@ -21,4 +21,18 @@ private protected Result() /// [JsonPropertyName("_meta")] public JsonObject? Meta { get; set; } + + /// + /// Gets or sets the type of the result, which allows the client to determine how to parse the result object. + /// + /// + /// + /// When absent or set to "complete", the result is a normal completed response. + /// When set to "incomplete", the result is an indicating + /// that additional input is needed before the request can be completed. + /// + /// + /// Defaults to , which is equivalent to "complete". + [JsonPropertyName("result_type")] + public string? ResultType { get; set; } } diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 961344c2c..c4090f99e 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -15,6 +15,7 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool { private readonly bool _structuredOutputRequiresWrapping; private readonly IReadOnlyList _metadata; + private readonly bool _deferTaskCreation; /// /// Creates an instance for a method, specified via a instance. @@ -173,7 +174,7 @@ options.OpenWorld is not null || tool.Execution.TaskSupport = ToolTaskSupport.Optional; } - return new AIFunctionMcpServerTool(function, tool, options?.Services, structuredOutputRequiresWrapping, options?.Metadata ?? []); + return new AIFunctionMcpServerTool(function, tool, options?.Services, structuredOutputRequiresWrapping, options?.Metadata ?? [], options?.DeferTaskCreation ?? false); } private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpServerToolCreateOptions? options) @@ -224,6 +225,11 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe newOptions.Execution ??= new ToolExecution(); newOptions.Execution.TaskSupport ??= taskSupport; } + + if (toolAttr._deferTaskCreation is bool deferTaskCreation) + { + newOptions.DeferTaskCreation = deferTaskCreation; + } } if (method.GetCustomAttribute() is { } descAttr) @@ -241,7 +247,7 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe internal AIFunction AIFunction { get; } /// Initializes a new instance of the class. - private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider? serviceProvider, bool structuredOutputRequiresWrapping, IReadOnlyList metadata) + private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider? serviceProvider, bool structuredOutputRequiresWrapping, IReadOnlyList metadata, bool deferTaskCreation) { ValidateToolName(tool.Name); @@ -250,11 +256,15 @@ private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider _structuredOutputRequiresWrapping = structuredOutputRequiresWrapping; _metadata = metadata; + _deferTaskCreation = deferTaskCreation; } /// public override Tool ProtocolTool { get; } + /// + public override bool DeferTaskCreation => _deferTaskCreation; + /// public override IReadOnlyList Metadata => _metadata; diff --git a/src/ModelContextProtocol.Core/Server/DeferredTaskCreationResult.cs b/src/ModelContextProtocol.Core/Server/DeferredTaskCreationResult.cs new file mode 100644 index 000000000..bd5b99f6e --- /dev/null +++ b/src/ModelContextProtocol.Core/Server/DeferredTaskCreationResult.cs @@ -0,0 +1,27 @@ +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.Server; + +/// +/// Contains the information the handler needs after the framework creates the deferred task. +/// +internal sealed class DeferredTaskCreationResult +{ + /// Gets the ID of the created task. + public required string TaskId { get; init; } + + /// Gets the session ID associated with the task. + public required string? SessionId { get; init; } + + /// Gets the task store for persisting task state. + public required IMcpTaskStore TaskStore { get; init; } + + /// Gets whether to send task status notifications. + public required bool SendNotifications { get; init; } + + /// Gets the function for sending task status notifications. + public required Func? NotifyTaskStatusFunc { get; init; } + + /// Gets the cancellation token for the task (TTL-based or explicit). + public required CancellationToken TaskCancellationToken { get; init; } +} diff --git a/src/ModelContextProtocol.Core/Server/DeferredTaskInfo.cs b/src/ModelContextProtocol.Core/Server/DeferredTaskInfo.cs new file mode 100644 index 000000000..b14cf8059 --- /dev/null +++ b/src/ModelContextProtocol.Core/Server/DeferredTaskInfo.cs @@ -0,0 +1,78 @@ +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.Server; + +/// +/// Holds the state needed for deferred task creation, where a tool handler performs +/// ephemeral MRTR exchanges before committing to a background task via +/// . +/// Stored on and carried across MRTR continuations. +/// +internal sealed class DeferredTaskInfo +{ + /// Gets the task metadata from the original client request. + public required McpTaskMetadata TaskMetadata { get; init; } + + /// Gets the JSON-RPC request ID of the current tools/call request. + public required RequestId OriginalRequestId { get; init; } + + /// Gets the original JSON-RPC request. + public required JsonRpcRequest OriginalRequest { get; init; } + + /// Gets the task store for persisting task state. + public required IMcpTaskStore TaskStore { get; init; } + + /// Gets whether to send task status notifications. + public required bool SendNotifications { get; init; } + + /// + /// Task that completes when the handler calls . + /// The framework races this against handler completion and MRTR exchanges. + /// + private readonly TaskCompletionSource _signalTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// + /// TCS that the framework completes after creating the task, allowing the handler to continue. + /// + private readonly TaskCompletionSource _ackTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Gets the task that completes when the handler requests task creation. + public Task SignalTask => _signalTcs.Task; + + /// + /// Called by the handler (via ) to signal + /// the framework that a task should be created. Awaits the framework's acknowledgment. + /// + /// The result containing the created task's context information. + /// was already called. + public async ValueTask RequestTaskCreationAsync(CancellationToken cancellationToken) + { + if (!_signalTcs.TrySetResult(true)) + { + throw new InvalidOperationException("CreateTaskAsync has already been called for this tool execution."); + } + + return await _ackTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Called by the framework after creating the task to unblock the handler. + /// + /// Task creation was already acknowledged. + public void AcknowledgeTaskCreation(DeferredTaskCreationResult result) + { + if (!_ackTcs.TrySetResult(result)) + { + throw new InvalidOperationException("Task creation was already acknowledged."); + } + } + + /// + /// Called by the framework when task creation fails, propagating the exception + /// to the handler so throws. + /// + public void AcknowledgeFailure(Exception exception) + { + _ackTcs.TrySetException(exception); + } +} diff --git a/src/ModelContextProtocol.Core/Server/DelegatingMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/DelegatingMcpServerTool.cs index 775930090..79e46fe4a 100644 --- a/src/ModelContextProtocol.Core/Server/DelegatingMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/DelegatingMcpServerTool.cs @@ -23,6 +23,9 @@ protected DelegatingMcpServerTool(McpServerTool innerTool) /// public override Tool ProtocolTool => _innerTool.ProtocolTool; + /// + public override bool DeferTaskCreation => _innerTool.DeferTaskCreation; + /// public override IReadOnlyList Metadata => _innerTool.Metadata; diff --git a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs index 957f58a51..b33e22bb0 100644 --- a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs @@ -1,5 +1,6 @@ using ModelContextProtocol.Protocol; -using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Nodes; namespace ModelContextProtocol.Server; @@ -15,6 +16,14 @@ internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport public override IServiceProvider? Services => server.Services; public override LoggingLevel? LoggingLevel => server.LoggingLevel; + /// + /// Gets or sets the MRTR context for the current request, if any. + /// Set by when an MRTR-aware handler invocation is in progress. + /// + internal MrtrContext? ActiveMrtrContext { get; set; } + + public override bool IsMrtrSupported => server.IsLowLevelMrtrAvailable(); + public override ValueTask DisposeAsync() => server.DisposeAsync(); public override IAsyncDisposable RegisterNotificationHandler(string method, Func handler) => server.RegisterNotificationHandler(method, handler); @@ -39,6 +48,16 @@ public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken public override Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) { + // When an MRTR context is active, intercept server-to-client requests (sampling, elicitation, roots) + // and route them through the MRTR mechanism instead of sending them over the wire. + // Task-based requests (SampleAsTaskAsync/ElicitAsTaskAsync) have a "task" property on their params + // and expect a CreateTaskResult response, so they must bypass MRTR and go over the wire. + if (ActiveMrtrContext is { } mrtrContext && + !(request.Params is JsonObject paramsObj && paramsObj.ContainsKey("task"))) + { + return SendRequestViaMrtrAsync(mrtrContext, request, cancellationToken); + } + if (request.Context is not null) { throw new ArgumentException("Only transports can provide a JsonRpcMessageContext."); @@ -51,4 +70,50 @@ public override Task SendRequestAsync(JsonRpcRequest request, C return server.SendRequestAsync(request, cancellationToken); } + + private async Task SendRequestViaMrtrAsync( + MrtrContext mrtrContext, JsonRpcRequest request, CancellationToken cancellationToken) + { + var inputRequest = new InputRequest + { + Method = request.Method, + Params = request.Params is { } paramsNode + ? JsonSerializer.Deserialize(paramsNode, McpJsonUtilities.JsonContext.Default.JsonElement) + : null, + }; + var inputResponse = await mrtrContext.RequestInputAsync(inputRequest, cancellationToken).ConfigureAwait(false); + + return new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(inputResponse.RawValue, McpJsonUtilities.JsonContext.Default.JsonElement), + }; + } + + /// + public override async ValueTask CreateTaskAsync(CancellationToken cancellationToken = default) + { + var deferredTask = ActiveMrtrContext?.DeferredTask + ?? throw new InvalidOperationException( + "CreateTaskAsync can only be called from a tool handler with DeferTaskCreation enabled " + + "when the client provides task metadata in the tools/call request."); + + // Signal the framework to create the task and wait for acknowledgment. + // RequestTaskCreationAsync is atomic — throws if already called. + var result = await deferredTask.RequestTaskCreationAsync(cancellationToken).ConfigureAwait(false); + + // Transition to task mode on the handler's async flow. + TaskExecutionContext.Current = new TaskExecutionContext + { + TaskId = result.TaskId, + SessionId = result.SessionId, + TaskStore = result.TaskStore, + SendNotifications = result.SendNotifications, + NotifyTaskStatusFunc = result.NotifyTaskStatusFunc, + }; + + // No more ephemeral MRTR — subsequent ElicitAsync/SampleAsync calls + // will go through SendRequestWithTaskStatusTrackingAsync instead. + ActiveMrtrContext = null; + } } diff --git a/src/ModelContextProtocol.Core/Server/McpServer.cs b/src/ModelContextProtocol.Core/Server/McpServer.cs index b8b41bdc3..049799c77 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.cs @@ -64,6 +64,59 @@ protected McpServer() /// Gets the last logging level set by the client, or if it's never been set. public abstract LoggingLevel? LoggingLevel { get; } + /// + /// Gets a value indicating whether the connected client supports Multi Round-Trip Requests (MRTR). + /// + /// + /// + /// When this property returns , tool handlers can throw + /// to return an + /// with and/or + /// to the client. + /// + /// + /// When this property returns , tool handlers should provide a fallback + /// experience (for example, returning a text message explaining that the client does not support + /// the required feature) instead of throwing . + /// + /// + [Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] + public virtual bool IsMrtrSupported => false; + + /// + /// Transitions the current tool execution from ephemeral MRTR mode to a background task. + /// + /// + /// + /// This method is only valid when called from a tool handler that has + /// set to + /// and the client provided task metadata in the tools/call request. + /// + /// + /// Before calling this method, + /// and use the ephemeral + /// MRTR mechanism (returning to the client). After calling this method, + /// the task is created and subsequent calls use the persistent workflow (task status + /// with tasks/result and tasks/input_response). + /// + /// + /// If the tool handler returns without calling this method, a normal (non-task) result is returned + /// to the client. + /// + /// + /// A token to cancel the task creation. + /// + /// The tool does not have enabled, or + /// the client did not provide task metadata, or this method was already called. + /// + [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] + public virtual ValueTask CreateTaskAsync(CancellationToken cancellationToken = default) + { + throw new InvalidOperationException( + "CreateTaskAsync can only be called from a tool handler with DeferTaskCreation enabled " + + "when the client provides task metadata in the tools/call request."); + } + /// /// Runs the server, listening for and handling client requests. /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 203856814..afb1a40ae 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -2,8 +2,10 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ModelContextProtocol.Protocol; +using System.Collections.Concurrent; using System.Runtime.CompilerServices; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.Server; @@ -27,6 +29,13 @@ internal sealed partial class McpServerImpl : McpServer private readonly McpSessionHandler _sessionHandler; private readonly SemaphoreSlim _disposeLock = new(1, 1); private readonly McpTaskCancellationTokenProvider? _taskCancellationTokenProvider; + private readonly ConcurrentDictionary _mrtrContinuations = new(); + private readonly ConcurrentDictionary _mrtrContextsByRequestId = new(); + + // Track MRTR handler tasks using the same inFlightCount + TCS pattern as + // McpSessionHandler.ProcessMessagesCoreAsync. Starts at 1 for DisposeAsync itself. + private int _mrtrInFlightCount = 1; + private readonly TaskCompletionSource _allMrtrHandlersCompleted = new(TaskCreationOptions.RunContinuationsAsynchronously); private ClientCapabilities? _clientCapabilities; private Implementation? _clientInfo; @@ -92,6 +101,9 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact ConfigureCompletion(options); ConfigureExperimentalAndExtensions(options); + // Wrap MRTR-eligible handlers AFTER all handler registration is complete. + ConfigureMrtr(); + // Register any notification handlers that were provided. if (options.Handlers.NotificationHandlers is { } notificationHandlers) { @@ -210,9 +222,35 @@ public override async ValueTask DisposeAsync() _disposed = true; + // Dispose the session handler first — cancels message processing and waits for all + // in-flight request handlers (including retries in AwaitMrtrHandlerAsync) to complete. + // After this returns, no new requests can be processed and no new MRTR continuations + // can be created, so _mrtrContinuations is effectively frozen. _taskCancellationTokenProvider?.Dispose(); _disposables.ForEach(d => d()); await _sessionHandler.DisposeAsync().ConfigureAwait(false); + + // Cancel all orphaned MRTR handlers still suspended in continuations (waiting for + // retries that will never arrive now that the session handler is disposed). + int cancelledCount = _mrtrContinuations.Count; + foreach (var continuation in _mrtrContinuations.Values) + { + continuation.CancelHandler(); + } + + if (cancelledCount > 0) + { + MrtrContinuationsCancelled(cancelledCount); + } + + // Wait for all MRTR handler tasks to complete using the same inFlightCount + TCS + // pattern as McpSessionHandler.ProcessMessagesCoreAsync. The count started at 1 + // (for DisposeAsync itself); decrementing it here triggers the drain if handlers + // are still in flight. ObserveHandlerCompletionAsync decrements for each handler. + if (Interlocked.Decrement(ref _mrtrInFlightCount) != 0) + { + await _allMrtrHandlersCompleted.Task.ConfigureAwait(false); + } } private void ConfigureInitialize(McpServerOptions options) @@ -230,8 +268,11 @@ private void ConfigureInitialize(McpServerOptions options) // Negotiate a protocol version. If the server options provide one, use that. // Otherwise, try to use whatever the client requested as long as it's supported. // If it's not supported, fall back to the latest supported version. + // Also accept the experimental protocol version when the server has it configured. string? protocolVersion = options.ProtocolVersion; - protocolVersion ??= request?.ProtocolVersion is string clientProtocolVersion && McpSessionHandler.SupportedProtocolVersions.Contains(clientProtocolVersion) ? + protocolVersion ??= request?.ProtocolVersion is string clientProtocolVersion && + (McpSessionHandler.SupportedProtocolVersions.Contains(clientProtocolVersion) || + (options.ExperimentalProtocolVersion is not null && clientProtocolVersion == options.ExperimentalProtocolVersion)) ? clientProtocolVersion : McpSessionHandler.LatestProtocolVersion; @@ -725,7 +766,33 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) McpErrorCode.InvalidParams); } - // Task augmentation requested - return CreateTaskResult + // When DeferTaskCreation is enabled, run the handler through the normal + // MRTR-wrapped path with deferred task context, allowing ephemeral MRTR + // exchanges before the tool calls CreateTaskAsync(). + if (tool.DeferTaskCreation) + { + // Attach deferred task info to the MrtrContext so CreateTaskAsync() + // and AwaitMrtrHandlerAsync can use it. The MrtrContext was already + // created by WrapHandlerWithMrtr and set on the per-request server. + if (request.Server is DestinationBoundMcpServer destinationServer && + destinationServer.ActiveMrtrContext is { } mrtrContext) + { + mrtrContext.DeferredTask = new DeferredTaskInfo + { + TaskMetadata = taskMetadata, + OriginalRequestId = request.JsonRpcRequest.Id, + OriginalRequest = request.JsonRpcRequest, + TaskStore = taskStore!, + SendNotifications = sendNotifications, + }; + } + + // Execute normally — the MRTR wrapper (WrapHandlerWithMrtr) will handle + // racing between handler completion, MRTR exchanges, and task creation. + return await tool.InvokeAsync(request, cancellationToken).ConfigureAwait(false); + } + + // Task augmentation requested with immediate creation return await ExecuteToolAsTaskAsync(tool, request, taskMetadata, taskStore, sendNotifications, cancellationToken).ConfigureAwait(false); } @@ -774,9 +841,18 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) } catch (Exception e) { - ToolCallError(request.Params?.Name ?? string.Empty, e); + // Skip logging for OperationCanceledException when the cancellation token + // is signaled — tool handler cancellation is an expected lifecycle event + // (client request cancellation, session shutdown, MRTR teardown), not a + // tool error. + // Skip logging for IncompleteResultException — it's normal MRTR control flow, + // not an error (the low-level API uses it to signal an IncompleteResult). + if (!(e is OperationCanceledException && cancellationToken.IsCancellationRequested) && e is not IncompleteResultException) + { + ToolCallError(request.Params?.Name ?? string.Empty, e); + } - if ((e is OperationCanceledException && cancellationToken.IsCancellationRequested) || e is McpProtocolException) + if ((e is OperationCanceledException && cancellationToken.IsCancellationRequested) || e is McpProtocolException || e is IncompleteResultException) { throw; } @@ -990,7 +1066,7 @@ private ValueTask InvokeHandlerAsync( { return _servicesScopePerRequest ? InvokeScopedAsync(handler, args, jsonRpcRequest, cancellationToken) : - handler(new(new DestinationBoundMcpServer(this, jsonRpcRequest.Context?.RelatedTransport), jsonRpcRequest, args), cancellationToken); + handler(new(CreateDestinationBoundServer(jsonRpcRequest), jsonRpcRequest, args), cancellationToken); async ValueTask InvokeScopedAsync( McpRequestHandler handler, @@ -1002,7 +1078,7 @@ async ValueTask InvokeScopedAsync( try { return await handler( - new RequestContext(new DestinationBoundMcpServer(this, jsonRpcRequest.Context?.RelatedTransport), jsonRpcRequest, args) + new RequestContext(CreateDestinationBoundServer(jsonRpcRequest), jsonRpcRequest, args) { Services = scope?.ServiceProvider ?? Services, }, @@ -1018,6 +1094,22 @@ async ValueTask InvokeScopedAsync( } } + /// + /// Creates a per-request and attaches any pending + /// MRTR context that was stored by . + /// + private DestinationBoundMcpServer CreateDestinationBoundServer(JsonRpcRequest jsonRpcRequest) + { + var server = new DestinationBoundMcpServer(this, jsonRpcRequest.Context?.RelatedTransport); + + if (_mrtrContextsByRequestId.TryRemove(jsonRpcRequest.Id, out var mrtrContext)) + { + server.ActiveMrtrContext = mrtrContext; + } + + return server; + } + private void SetHandler( string method, McpRequestHandler handler, @@ -1106,6 +1198,536 @@ internal static LoggingLevel ToLoggingLevel(LogLevel level) => _ => Protocol.LoggingLevel.Emergency, }; + /// + /// Checks whether the negotiated protocol version enables MRTR. + /// + internal bool ClientSupportsMrtr() => + _negotiatedProtocolVersion is not null && + _negotiatedProtocolVersion == ServerOptions.ExperimentalProtocolVersion; + + /// + /// Checks whether the low-level MRTR API () is available + /// for the current request. Returns in all cases except stateless mode + /// with a client that hasn't negotiated MRTR — that's the one configuration where nobody can + /// drive the retry loop (the server can't send JSON-RPC requests to the client, and the client + /// doesn't know about IncompleteResult). + /// + internal bool IsLowLevelMrtrAvailable() => + ClientSupportsMrtr() || + _sessionTransport is not StreamableHttpServerTransport { Stateless: true }; + + /// + /// Wraps MRTR-eligible request handlers so that when a handler calls ElicitAsync/SampleAsync, + /// an IncompleteResult is returned early and the handler is suspended until the retry arrives. + /// + private void ConfigureMrtr() + { + // Wrap all methods that may trigger MRTR (server calling ElicitAsync/SampleAsync/RequestRootsAsync + // during handler execution). These methods may produce IncompleteResult if the handler needs input. + WrapHandlerWithMrtr(RequestMethods.ToolsCall); + WrapHandlerWithMrtr(RequestMethods.PromptsGet); + WrapHandlerWithMrtr(RequestMethods.ResourcesRead); + } + + /// + /// Replaces an existing request handler entry with an MRTR-aware wrapper that supports + /// handler suspension and IncompleteResult responses. + /// + private void WrapHandlerWithMrtr(string method) + { + if (!_requestHandlers.TryGetValue(method, out var originalHandler)) + { + return; + } + + _requestHandlers[method] = async (request, cancellationToken) => + { + // In stateless mode, each request creates a new server instance that never saw the + // initialize handshake, so _negotiatedProtocolVersion is null. Pick it up from the + // Mcp-Protocol-Version header that the transport layer flowed via JsonRpcMessageContext. + if (_negotiatedProtocolVersion is null && + request.Context?.ProtocolVersion is { } headerProtocolVersion) + { + _negotiatedProtocolVersion = headerProtocolVersion; + } + + // Check for MRTR retry: if requestState is present, look up the continuation. + if (request.Params is JsonObject paramsObj && + paramsObj.TryGetPropertyValue("requestState", out var requestStateNode) && + requestStateNode?.GetValueKind() == JsonValueKind.String && + requestStateNode.GetValue() is { } requestState) + { + if (_mrtrContinuations.TryRemove(requestState, out var existingContinuation)) + { + // High-level MRTR retry: resume the suspended handler with client responses. + IDictionary? inputResponses = null; + if (paramsObj.TryGetPropertyValue("inputResponses", out var responsesNode) && responsesNode is not null) + { + inputResponses = JsonSerializer.Deserialize(responsesNode, McpJsonUtilities.JsonContext.Default.IDictionaryStringInputResponse); + } + + var exchange = existingContinuation.PendingExchange!; + var nextExchangeTask = existingContinuation.MrtrContext.ResetForNextExchange(exchange); + + if (inputResponses is not null && + inputResponses.TryGetValue(exchange.Key, out var response)) + { + if (!exchange.ResponseTcs.TrySetResult(response)) + { + throw new McpProtocolException( + $"MRTR exchange '{exchange.Key}' was already completed (possibly cancelled).", + McpErrorCode.InternalError); + } + } + else + { + if (!exchange.ResponseTcs.TrySetException( + new McpProtocolException($"Missing input response for key '{exchange.Key}'.", McpErrorCode.InvalidParams))) + { + throw new McpProtocolException( + $"MRTR exchange '{exchange.Key}' was already completed (possibly cancelled).", + McpErrorCode.InternalError); + } + } + + return await AwaitMrtrHandlerAsync( + existingContinuation.HandlerTask, existingContinuation, nextExchangeTask, cancellationToken).ConfigureAwait(false); + } + + // Low-level MRTR retry or invalid requestState: no continuation found. + // Fall through to the standard MRTR-aware invocation path below. The retry data + // (inputResponses, requestState) is already in the deserialized request params + // for low-level handlers to access, and the MrtrContext will be set up for + // high-level handlers that call ElicitAsync/SampleAsync. + } + + // Not a retry, or a retry without a continuation - check if the client supports MRTR + // and the server is stateful (the high-level await path requires storing continuations). + if (!ClientSupportsMrtr() || _sessionTransport is StreamableHttpServerTransport { Stateless: true }) + { + return await InvokeWithIncompleteResultHandlingAsync(originalHandler, request, cancellationToken).ConfigureAwait(false); + } + + // Start a new MRTR-aware handler invocation. + var mrtrContext = new MrtrContext(); + + // Create a long-lived CTS for the handler that survives across retries. + // The original request's combinedCts will be disposed when this lambda returns, + // breaking the cancellation chain. This CTS keeps the handler cancellable. + // Like Kestrel's HttpContext.RequestAborted, the CTS is never disposed — Cancel() + // is thread-safe with itself, and not disposing avoids deadlock risks from + // calling Cancel/Dispose inside locks or Interlocked guards. + var handlerCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + // Store the MrtrContext so CreateDestinationBoundServer can pick it up and set it + // on the per-request DestinationBoundMcpServer. This is picked up synchronously + // before any await, so the finally cleanup is safe. + _mrtrContextsByRequestId[request.Id] = mrtrContext; + Task handlerTask; + try + { + handlerTask = originalHandler(request, handlerCts.Token); + } + finally + { + _mrtrContextsByRequestId.TryRemove(request.Id, out _); + } + + // Wrap handler state into a continuation for lifecycle management across retries. + var continuation = new MrtrContinuation(handlerCts, handlerTask, mrtrContext); + + // Track the handler task for lifecycle management. The observer logs unhandled + // exceptions and decrements _mrtrInFlightCount when the handler completes, + // mirroring how McpSessionHandler tracks in-flight handlers. + Interlocked.Increment(ref _mrtrInFlightCount); + _ = ObserveHandlerCompletionAsync(handlerTask); + + return await AwaitMrtrHandlerAsync( + handlerTask, continuation, mrtrContext.InitialExchangeTask, cancellationToken).ConfigureAwait(false); + }; + } + + /// + /// Invokes a handler and catches to convert it to an + /// JSON response. When MRTR is negotiated or the server is stateless, + /// the result is serialized directly. Otherwise, input requests are resolved via standard JSON-RPC + /// calls (elicitation, sampling, roots) and the handler is retried with the responses — allowing + /// MRTR-native tools to work transparently with clients that don't support MRTR. + /// + private async Task InvokeWithIncompleteResultHandlingAsync( + Func> handler, + JsonRpcRequest request, + CancellationToken cancellationToken) + { + const int MaxRetries = 10; + + for (int retry = 0; ; retry++) + { + try + { + return await handler(request, cancellationToken).ConfigureAwait(false); + } + catch (IncompleteResultException ex) + { + // If the client natively supports MRTR, serialize and return directly — + // the client will drive the retry loop. + if (ClientSupportsMrtr()) + { + return SerializeIncompleteResult(ex.IncompleteResult); + } + + // In stateless mode without MRTR, the server can't resolve input requests via + // JSON-RPC (no persistent session for server-to-client requests), and the client + // won't recognize the IncompleteResult. This is the one unsupported configuration. + if (_sessionTransport is StreamableHttpServerTransport { Stateless: true }) + { + throw new McpException( + "A tool handler returned an incomplete result, but the server is stateless and the client does not support MRTR. " + + "MRTR-native tools require either an MRTR-capable client or a stateful server for backward-compatible resolution.", ex); + } + + // Backcompat: resolve input requests via standard JSON-RPC calls and retry the handler. + if (ex.IncompleteResult.InputRequests is not { Count: > 0 } inputRequests) + { + throw new McpException( + "A tool handler returned an incomplete result without input requests, and the client does not support MRTR.", ex); + } + + if (retry >= MaxRetries) + { + throw new McpException( + $"MRTR-native tool exceeded {MaxRetries} retry rounds without completing.", ex); + } + + // Resolve each input request by sending the corresponding JSON-RPC call to the client. + var inputResponses = new Dictionary(inputRequests.Count); + foreach (var kvp in inputRequests) + { + inputResponses[kvp.Key] = await ResolveInputRequestAsync(kvp.Value, cancellationToken).ConfigureAwait(false); + } + + // Reconstruct request params with inputResponses and requestState for the retry. + var paramsObj = request.Params?.DeepClone() as JsonObject ?? new JsonObject(); + paramsObj["inputResponses"] = JsonSerializer.SerializeToNode( + (IDictionary)inputResponses, McpJsonUtilities.JsonContext.Default.IDictionaryStringInputResponse); + + if (ex.IncompleteResult.RequestState is { } requestState) + { + paramsObj["requestState"] = requestState; + } + + request = new JsonRpcRequest + { + Id = request.Id, + Method = request.Method, + Params = paramsObj, + Context = request.Context, + }; + } + } + } + + /// + /// Resolves a single MRTR by dispatching it as a standard JSON-RPC + /// request to the client. This is the server-side mirror of the client's input resolution logic, + /// used for backward compatibility when the client doesn't support MRTR. + /// + private async Task ResolveInputRequestAsync(InputRequest inputRequest, CancellationToken cancellationToken) + { + switch (inputRequest.Method) + { + case RequestMethods.ElicitationCreate: + var elicitParams = inputRequest.ElicitationParams + ?? throw new McpException("Failed to deserialize elicitation parameters from MRTR input request."); + var elicitResult = await ElicitAsync(elicitParams, cancellationToken).ConfigureAwait(false); + return InputResponse.FromElicitResult(elicitResult); + + case RequestMethods.SamplingCreateMessage: + var samplingParams = inputRequest.SamplingParams + ?? throw new McpException("Failed to deserialize sampling parameters from MRTR input request."); + var samplingResult = await SampleAsync(samplingParams, cancellationToken).ConfigureAwait(false); + return InputResponse.FromSamplingResult(samplingResult); + + case RequestMethods.RootsList: + var rootsParams = inputRequest.RootsParams ?? new ListRootsRequestParams(); + var rootsResult = await RequestRootsAsync(rootsParams, cancellationToken).ConfigureAwait(false); + return InputResponse.FromRootsResult(rootsResult); + + default: + throw new McpException($"Unsupported input request method: '{inputRequest.Method}'."); + } + } + + /// + /// Awaits the outcome of an MRTR-enabled handler invocation. + /// If the handler completes, returns its result. If an exchange arrives (handler needs input), + /// builds and returns an IncompleteResult and stores the continuation for future retries. + /// If the handler throws , the result is returned directly + /// without storing a continuation (low-level MRTR path). + /// When deferred task creation is enabled, also races against the task creation signal. + /// + private async Task AwaitMrtrHandlerAsync( + Task handlerTask, + MrtrContinuation continuation, + Task exchangeTask, + CancellationToken cancellationToken) + { + // Link the current request's cancellation to the handler's long-lived CTS. + // On the initial call this is redundant (handlerCts is already linked to cancellationToken) + // but on retries this is critical: the retry's combinedCts cancellation must flow to the handler. + // This is how notifications/cancelled for the retry's request ID reaches the handler. + using var registration = cancellationToken.Register( + static state => ((MrtrContinuation)state!).CancelHandler(), continuation); + + var deferredTask = continuation.MrtrContext.DeferredTask; + + // Race handler against MRTR exchange and optionally the deferred task creation signal. + Task completedTask; + if (deferredTask is not null) + { + completedTask = await Task.WhenAny(handlerTask, exchangeTask, deferredTask.SignalTask).ConfigureAwait(false); + } + else + { + completedTask = await Task.WhenAny(handlerTask, exchangeTask).ConfigureAwait(false); + } + + if (completedTask == handlerTask) + { + // Handler completed - return its result, propagate its exception, or handle IncompleteResultException. + return await AwaitHandlerWithIncompleteResultHandlingAsync(handlerTask).ConfigureAwait(false); + } + + if (deferredTask is not null && completedTask == deferredTask.SignalTask) + { + // Handler called CreateTaskAsync() — transition to task mode. + return await HandleDeferredTaskCreationAsync(handlerTask, continuation, deferredTask, cancellationToken).ConfigureAwait(false); + } + + // Exchange arrived - handler needs input from the client (high-level MRTR path). + var exchange = await exchangeTask.ConfigureAwait(false); + + var correlationId = Guid.NewGuid().ToString("N"); + var incompleteResult = new IncompleteResult + { + InputRequests = new Dictionary { [exchange.Key] = exchange.InputRequest }, + RequestState = correlationId, + }; + + // Store the continuation so the retry can resume the handler. + continuation.PendingExchange = exchange; + _mrtrContinuations[correlationId] = continuation; + + return SerializeIncompleteResult(incompleteResult); + } + + /// + /// Fire-and-forget observer for an MRTR handler task. Logs unhandled exceptions at Error + /// level and decrements when the handler completes, following + /// the same in-flight tracking pattern as . + /// + private async Task ObserveHandlerCompletionAsync(Task handlerTask) + { + try + { + await handlerTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Handler cancelled — expected lifecycle event (disposal, client cancel, session shutdown). + } + catch (IncompleteResultException) + { + // Low-level MRTR: handler explicitly signaling an IncompleteResult. Not an error. + } + catch (Exception ex) + { + MrtrHandlerError(ex); + } + finally + { + if (Interlocked.Decrement(ref _mrtrInFlightCount) == 0) + { + _allMrtrHandlersCompleted.TrySetResult(true); + } + } + } + + /// + /// Awaits a handler task, catching to convert it to an + /// JSON response without storing a continuation. + /// + private static async Task AwaitHandlerWithIncompleteResultHandlingAsync(Task handlerTask) + { + try + { + return await handlerTask.ConfigureAwait(false); + } + catch (IncompleteResultException ex) + { + return SerializeIncompleteResult(ex.IncompleteResult); + } + } + + private static JsonNode? SerializeIncompleteResult(IncompleteResult incompleteResult) => + JsonSerializer.SerializeToNode(incompleteResult, McpJsonUtilities.JsonContext.Default.IncompleteResult); + + /// + /// Handles the transition from ephemeral MRTR to task-based execution when the handler + /// calls . + /// Creates the task, acknowledges the handler, re-links the handler CTS to the task's + /// cancellation token, and returns CreateTaskResult to the client. + /// + private async Task HandleDeferredTaskCreationAsync( + Task handlerTask, + MrtrContinuation continuation, + DeferredTaskInfo deferredTask, + CancellationToken cancellationToken) + { + var taskStore = deferredTask.TaskStore; + var sendNotifications = deferredTask.SendNotifications; + + Protocol.McpTask mcpTask; + CancellationToken taskCancellationToken; + try + { + // Create the task in the task store. + mcpTask = await taskStore.CreateTaskAsync( + deferredTask.TaskMetadata, + deferredTask.OriginalRequestId, + deferredTask.OriginalRequest, + SessionId, + cancellationToken).ConfigureAwait(false); + + // Register the task for TTL-based cancellation. + taskCancellationToken = _taskCancellationTokenProvider!.RequestToken(mcpTask.TaskId, mcpTask.TimeToLive); + + // Re-link the handler's CTS to the task's cancellation token so handler + // cancellation tracks the task lifecycle (TTL expiration, explicit cancel) + // instead of the original request. + taskCancellationToken.Register( + static state => ((MrtrContinuation)state!).CancelHandler(), continuation); + + // Update task status to working. + var workingTask = await taskStore.UpdateTaskStatusAsync( + mcpTask.TaskId, + McpTaskStatus.Working, + null, + SessionId, + CancellationToken.None).ConfigureAwait(false); + + if (sendNotifications) + { + _ = NotifyTaskStatusAsync(workingTask, CancellationToken.None); + } + } + catch (Exception ex) + { + // If task creation fails, propagate the exception to the handler + // so CreateTaskAsync() throws instead of blocking forever. + deferredTask.AcknowledgeFailure(ex); + throw; + } + + // Acknowledge the handler so CreateTaskAsync() returns and the handler continues. + deferredTask.AcknowledgeTaskCreation(new DeferredTaskCreationResult + { + TaskId = mcpTask.TaskId, + SessionId = SessionId, + TaskStore = taskStore, + SendNotifications = sendNotifications, + NotifyTaskStatusFunc = NotifyTaskStatusAsync, + TaskCancellationToken = taskCancellationToken, + }); + + // Track the handler task in the background. The handler is already tracked by + // ObserveHandlerCompletionAsync (via _mrtrInFlightCount), so no additional + // in-flight tracking is needed here — just status updates. + _ = TrackDeferredHandlerTaskAsync(handlerTask, mcpTask, taskStore, sendNotifications); + + // Return CreateTaskResult to the client. + var createTaskResult = new CallToolResult { Task = mcpTask }; + return JsonSerializer.SerializeToNode(createTaskResult, McpJsonUtilities.JsonContext.Default.CallToolResult); + } + + /// + /// Tracks a deferred handler task after task creation, updating task status and storing results. + /// The handler task is already tracked by for + /// in-flight counting and error logging. + /// + private async Task TrackDeferredHandlerTaskAsync( + Task handlerTask, + Protocol.McpTask mcpTask, + IMcpTaskStore taskStore, + bool sendNotifications) + { + try + { + var resultNode = await handlerTask.ConfigureAwait(false); + + CallToolResult? result = null; + if (resultNode is not null) + { + result = JsonSerializer.Deserialize(resultNode, McpJsonUtilities.JsonContext.Default.CallToolResult); + } + + var finalStatus = result?.IsError is true ? McpTaskStatus.Failed : McpTaskStatus.Completed; + var resultElement = result is not null + ? JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.CallToolResult) + : default; + + var finalTask = await taskStore.StoreTaskResultAsync( + mcpTask.TaskId, + finalStatus, + resultElement, + SessionId, + CancellationToken.None).ConfigureAwait(false); + + if (sendNotifications) + { + _ = NotifyTaskStatusAsync(finalTask, CancellationToken.None); + } + } + catch (OperationCanceledException) + { + // After task creation, any handler cancellation is legitimate — + // task TTL expiration, explicit tasks/cancel, or session disposal. + } + catch (Exception ex) + { + // Error logging is already handled by ObserveHandlerCompletionAsync. + var errorResult = new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = $"Task execution failed: {ex.Message}" }], + }; + + try + { + var errorResultElement = JsonSerializer.SerializeToElement(errorResult, McpJsonUtilities.JsonContext.Default.CallToolResult); + var failedTask = await taskStore.StoreTaskResultAsync( + mcpTask.TaskId, + McpTaskStatus.Failed, + errorResultElement, + SessionId, + CancellationToken.None).ConfigureAwait(false); + + if (sendNotifications) + { + _ = NotifyTaskStatusAsync(failedTask, CancellationToken.None); + } + } + catch + { + // If we can't store the error result, the task will remain in "working" status. + } + } + finally + { + _taskCancellationTokenProvider!.Complete(mcpTask.TaskId); + } + } + [LoggerMessage(Level = LogLevel.Error, Message = "\"{ToolName}\" threw an unhandled exception.")] private partial void ToolCallError(string toolName, Exception exception); @@ -1124,6 +1746,12 @@ internal static LoggingLevel ToLoggingLevel(LogLevel level) => [LoggerMessage(Level = LogLevel.Information, Message = "ReadResource \"{ResourceUri}\" completed.")] private partial void ReadResourceCompleted(string resourceUri); + [LoggerMessage(Level = LogLevel.Debug, Message = "Cancelled {Count} pending MRTR continuation(s) during session disposal.")] + private partial void MrtrContinuationsCancelled(int count); + + [LoggerMessage(Level = LogLevel.Debug, Message = "An MRTR handler threw an unhandled exception.")] + private partial void MrtrHandlerError(Exception exception); + /// /// Executes a tool call as a task and returns a CallToolTaskResult immediately. /// @@ -1179,6 +1807,13 @@ private async ValueTask ExecuteToolAsTaskAsync( NotifyTaskStatusFunc = NotifyTaskStatusAsync }; + // MRTR doesn't apply here because the task hasn't opted into deferred creation, + // and the original request was already answered with CreateTaskResult. + if (request.Server is DestinationBoundMcpServer destinationServer) + { + destinationServer.ActiveMrtrContext = null; + } + try { // Update task status to working diff --git a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs index 6da8bbfbe..0f12c253c 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs @@ -238,4 +238,23 @@ public McpServerFilters Filters /// [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] public bool SendTaskStatusNotifications { get; set; } + + /// + /// Gets or sets an experimental protocol version that enables draft protocol features such as + /// Multi Round-Trip Requests (MRTR). + /// + /// + /// + /// When set, this version is accepted from clients during protocol version negotiation, and MRTR + /// is activated when the negotiated version matches. If a client does not request this version, + /// the server negotiates to the latest stable version and uses standard server-to-client JSON-RPC + /// requests for sampling and elicitation. + /// + /// + /// This property is intended for proof-of-concept and testing of draft MCP specification features + /// that have not yet been ratified. + /// + /// + [Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] + public string? ExperimentalProtocolVersion { get; set; } } diff --git a/src/ModelContextProtocol.Core/Server/McpServerTool.cs b/src/ModelContextProtocol.Core/Server/McpServerTool.cs index e2a9a34e0..cf71daa87 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerTool.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Text.Json; @@ -157,6 +158,13 @@ protected McpServerTool() /// Gets the protocol type for this instance. public abstract Tool ProtocolTool { get; } + /// + /// Gets a value indicating whether the tool defers task creation, allowing + /// ephemeral MRTR exchanges before committing to a background task. + /// + [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] + public virtual bool DeferTaskCreation => false; + /// /// Gets the metadata for this tool instance. /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs index d67bac18c..a02fc1cfc 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs @@ -158,6 +158,7 @@ public sealed class McpServerToolAttribute : Attribute internal bool? _openWorld; internal bool? _readOnly; internal ToolTaskSupport? _taskSupport; + internal bool? _deferTaskCreation; /// /// Initializes a new instance of the class. @@ -325,4 +326,28 @@ public ToolTaskSupport TaskSupport get => _taskSupport ?? ToolTaskSupport.Forbidden; set => _taskSupport = value; } + + /// + /// Gets or sets a value indicating whether the tool defers task creation, allowing + /// ephemeral MRTR exchanges before committing to a background task via + /// . + /// + /// + /// if the tool handler can perform MRTR interactions before + /// deciding whether to create a task; if a task is created + /// immediately when the client provides task metadata. + /// The default is . + /// + /// + /// When enabled and the client provides task metadata, the handler runs through the + /// normal MRTR-wrapped path. The handler may call + /// to transition to a + /// background task, or it may return a normal result without creating a task. + /// + [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] + public bool DeferTaskCreation + { + get => _deferTaskCreation ?? false; + set => _deferTaskCreation = value; + } } diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs index 88d718d13..e805ee57e 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs @@ -214,6 +214,20 @@ public sealed class McpServerToolCreateOptions [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] public ToolExecution? Execution { get; set; } + /// + /// Gets or sets a value indicating whether the tool defers task creation, allowing + /// ephemeral MRTR exchanges before committing to a background task via + /// . + /// + /// + /// When and the client provides task metadata, the handler runs through + /// the normal MRTR-wrapped path. The handler may call + /// to transition to a background task, + /// or it may return a normal result without creating a task. + /// + [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] + public bool DeferTaskCreation { get; set; } + /// /// Creates a shallow clone of the current instance. /// @@ -236,5 +250,6 @@ internal McpServerToolCreateOptions Clone() => Icons = Icons, Meta = Meta, Execution = Execution, + DeferTaskCreation = DeferTaskCreation, }; } diff --git a/src/ModelContextProtocol.Core/Server/MrtrContext.cs b/src/ModelContextProtocol.Core/Server/MrtrContext.cs new file mode 100644 index 000000000..85bc7c8df --- /dev/null +++ b/src/ModelContextProtocol.Core/Server/MrtrContext.cs @@ -0,0 +1,85 @@ +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.Server; + +/// +/// Manages the MRTR (Multi Round-Trip Request) coordination between a handler and the pipeline. +/// When a handler calls or +/// , +/// the handler sets the exchange TCS and suspends on a response TCS. The pipeline detects the exchange +/// via or the task returned by , +/// sends an , and later completes the response TCS when the retry arrives. +/// +internal sealed class MrtrContext +{ + private TaskCompletionSource _exchangeTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _nextInputRequestId; + + /// + /// Gets the task for the initial MRTR exchange. Set once in the constructor and never changes. + /// For subsequent exchanges after a retry, use the task returned by . + /// + public Task InitialExchangeTask { get; } + + public MrtrContext() + { + InitialExchangeTask = _exchangeTcs.Task; + } + + /// + /// Gets or sets the deferred task creation info, if the tool opted into deferred task creation + /// and the client provided task metadata. When set, + /// uses this to signal the framework. + /// + public DeferredTaskInfo? DeferredTask { get; set; } + + /// + /// Prepares the context for the next round of exchange after a retry arrives. + /// Uses to atomically validate that + /// still references the TCS that produced , + /// ensuring concurrent calls reliably fail. + /// + /// The exchange from the previous round whose + /// response has been (or is about to be) completed. + /// A task that completes when the handler requests input via + /// . + /// The context state was modified concurrently. + public Task ResetForNextExchange(MrtrExchange previousExchange) + { + var newTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (Interlocked.CompareExchange(ref _exchangeTcs, newTcs, previousExchange.SourceTcs) != previousExchange.SourceTcs) + { + throw new InvalidOperationException("MrtrContext was modified concurrently."); + } + + return newTcs.Task; + } + + /// + /// Called by + /// or + /// to request input from the client via the MRTR mechanism. + /// + /// The input request describing what the server needs. + /// A token to cancel the wait for input. + /// The client's response to the input request. + /// A concurrent server-to-client request is already pending. + public async Task RequestInputAsync(InputRequest inputRequest, CancellationToken cancellationToken) + { + var key = $"input_{Interlocked.Increment(ref _nextInputRequestId)}"; + var tcs = _exchangeTcs; + 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 + // are not supported. + if (!tcs.TrySetResult(exchange)) + { + throw new InvalidOperationException( + "Concurrent server-to-client requests are not supported. " + + "Await each ElicitAsync, SampleAsync, or RequestRootsAsync call before making another."); + } + + return await exchange.ResponseTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/ModelContextProtocol.Core/Server/MrtrContinuation.cs b/src/ModelContextProtocol.Core/Server/MrtrContinuation.cs new file mode 100644 index 000000000..0a8a6e719 --- /dev/null +++ b/src/ModelContextProtocol.Core/Server/MrtrContinuation.cs @@ -0,0 +1,50 @@ +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Server; + +/// +/// Represents the lifecycle state for an MRTR handler invocation across retries. +/// Created when the handler starts and stored in _mrtrContinuations when +/// the handler suspends waiting for client input. +/// +internal sealed class MrtrContinuation +{ + private readonly CancellationTokenSource _handlerCts; + + public MrtrContinuation(CancellationTokenSource handlerCts, Task handlerTask, MrtrContext mrtrContext) + { + _handlerCts = handlerCts; + HandlerTask = handlerTask; + MrtrContext = mrtrContext; + } + + /// + /// Gets a token that cancels when the handler should be aborted. + /// Passed to the handler at creation and remains valid across retries. + /// + public CancellationToken HandlerToken => _handlerCts.Token; + + /// + /// The handler task that is suspended awaiting input. + /// + public Task HandlerTask { get; } + + /// + /// The MRTR context for the handler's async flow. + /// + public MrtrContext MrtrContext { get; } + + /// + /// The exchange that is awaiting a response from the client. + /// Set each time the handler suspends on a new exchange. + /// + public MrtrExchange? PendingExchange { get; set; } + + /// + /// Cancels the handler. Safe to call multiple times and concurrently — + /// is thread-safe with itself. + /// The CTS is intentionally never disposed to avoid deadlock risks from + /// calling Cancel/Dispose inside synchronization primitives. + /// + public void CancelHandler() => _handlerCts.Cancel(); +} diff --git a/src/ModelContextProtocol.Core/Server/MrtrExchange.cs b/src/ModelContextProtocol.Core/Server/MrtrExchange.cs new file mode 100644 index 000000000..cf0a86af4 --- /dev/null +++ b/src/ModelContextProtocol.Core/Server/MrtrExchange.cs @@ -0,0 +1,41 @@ +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.Server; + +/// +/// Represents a single exchange between the handler and the pipeline during an MRTR flow. +/// The handler creates the exchange and awaits the response TCS. The pipeline reads the exchange, +/// sends the to the client, and completes the TCS when the response arrives. +/// +internal sealed class MrtrExchange +{ + public MrtrExchange(string key, InputRequest inputRequest, TaskCompletionSource sourceTcs) + { + Key = key; + InputRequest = inputRequest; + SourceTcs = sourceTcs; + ResponseTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + /// + /// The unique key identifying this exchange within the MRTR round trip. + /// + public string Key { get; } + + /// + /// The input request that needs to be fulfilled by the client. + /// + public InputRequest InputRequest { get; } + + /// + /// The that this exchange was set as the result of. + /// Used by on retry to validate + /// the expected state via . + /// + internal TaskCompletionSource SourceTcs { get; } + + /// + /// The TCS that will be completed with the client's response. + /// + public TaskCompletionSource ResponseTcs { get; } +} diff --git a/tests/Common/Utils/ServerMessageTracker.cs b/tests/Common/Utils/ServerMessageTracker.cs new file mode 100644 index 000000000..c12de1e08 --- /dev/null +++ b/tests/Common/Utils/ServerMessageTracker.cs @@ -0,0 +1,85 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Collections.Concurrent; +using System.Text.Json.Nodes; +using Xunit; + +namespace ModelContextProtocol.Tests.Utils; + +/// +/// Tracks MRTR protocol mode via incoming and outgoing message filters. +/// Used by MRTR tests to verify the correct protocol mode (MRTR vs legacy) was used. +/// +internal sealed class ServerMessageTracker +{ + private static readonly HashSet LegacyMrtrMethods = + [ + RequestMethods.ElicitationCreate, + RequestMethods.SamplingCreateMessage, + RequestMethods.RootsList, + ]; + + private readonly ConcurrentBag _legacyRequestMethods = []; + private int _mrtrRetryCount; + private int _incompleteResultCount; + + /// + /// Adds incoming and outgoing message filters to track MRTR protocol usage. + /// Call this in services.Configure<McpServerOptions> or AddMcpServer callbacks. + /// + public void AddFilters(McpMessageFilters messageFilters) + { + // Track outgoing legacy JSON-RPC requests and IncompleteResult responses. + messageFilters.OutgoingFilters.Add(next => async (context, cancellationToken) => + { + if (context.JsonRpcMessage is JsonRpcRequest request && LegacyMrtrMethods.Contains(request.Method)) + { + _legacyRequestMethods.Add(request.Method); + } + else if (context.JsonRpcMessage is JsonRpcResponse response && + response.Result is JsonObject resultObj && + resultObj.TryGetPropertyValue("result_type", out var resultTypeNode) && + resultTypeNode?.GetValue() == "incomplete") + { + Interlocked.Increment(ref _incompleteResultCount); + } + + await next(context, cancellationToken); + }); + + // Track incoming MRTR retries (requests with inputResponses or requestState in params). + messageFilters.IncomingFilters.Add(next => async (context, cancellationToken) => + { + if (context.JsonRpcMessage is JsonRpcRequest request && + request.Params is JsonObject paramsObj && + (paramsObj.ContainsKey("inputResponses") || paramsObj.ContainsKey("requestState"))) + { + Interlocked.Increment(ref _mrtrRetryCount); + } + + await next(context, cancellationToken); + }); + } + + /// + /// Asserts that MRTR was used: at least one IncompleteResult response was sent + /// and no legacy JSON-RPC requests (elicitation/create, sampling/createMessage, roots/list) were sent. + /// + public void AssertMrtrUsed() + { + Assert.True(_incompleteResultCount > 0, + "Expected at least one IncompleteResult response (MRTR mode), but none were detected."); + Assert.Empty(_legacyRequestMethods); + } + + /// + /// Asserts that legacy mode was used: at least one legacy JSON-RPC request was sent + /// and no MRTR retries or IncompleteResult responses were detected. + /// + public void AssertMrtrNotUsed() + { + Assert.NotEmpty(_legacyRequestMethods); + Assert.Equal(0, _mrtrRetryCount); + Assert.Equal(0, _incompleteResultCount); + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStatelessTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStatelessTests.cs index 5552b5395..ac19953bf 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStatelessTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStatelessTests.cs @@ -1,7 +1,45 @@ -namespace ModelContextProtocol.AspNetCore.Tests; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.AspNetCore.Tests; public class MapMcpStatelessTests(ITestOutputHelper outputHelper) : MapMcpStreamableHttpTests(outputHelper) { protected override bool UseStreamableHttp => true; protected override bool Stateless => true; + + [Fact] + public async Task EnablePollingAsync_ThrowsInvalidOperationException_InStatelessMode() + { + InvalidOperationException? capturedException = null; + var pollingTool = McpServerTool.Create(async (RequestContext context) => + { + try + { + await context.EnablePollingAsync(retryInterval: TimeSpan.FromSeconds(1)); + } + catch (InvalidOperationException ex) + { + capturedException = ex; + } + + return "Complete"; + }, options: new() { Name = "polling_tool" }); + + Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools([pollingTool]); + + await using var app = Builder.Build(); + app.MapMcp(); + + await app.StartAsync(TestContext.Current.CancellationToken); + + await using var mcpClient = await ConnectAsync(); + + await mcpClient.CallToolAsync("polling_tool", cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(capturedException); + Assert.Contains("stateless", capturedException.Message, StringComparison.OrdinalIgnoreCase); + } } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs index 4f2d5aaeb..b95ea67a6 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpStreamableHttpTests.cs @@ -347,9 +347,9 @@ public async Task StreamableHttpClient_SendsMcpProtocolVersionHeader_AfterInitia await app.StartAsync(TestContext.Current.CancellationToken); - await using var mcpClient = await ConnectAsync(clientOptions: new() + await using var mcpClient = await ConnectAsync(configureClient: options => { - ProtocolVersion = "2025-06-18", + options.ProtocolVersion = "2025-06-18"; }); Assert.Equal("2025-06-18", mcpClient.NegotiatedProtocolVersion); @@ -457,41 +457,6 @@ public async Task CanResumeSessionWithMapMcpAndRunSessionHandler() Assert.Equal(1, runSessionCount); } - [Fact] - public async Task EnablePollingAsync_ThrowsInvalidOperationException_InStatelessMode() - { - Assert.SkipUnless(Stateless, "This test only applies to stateless mode."); - - InvalidOperationException? capturedException = null; - var pollingTool = McpServerTool.Create(async (RequestContext context) => - { - try - { - await context.EnablePollingAsync(retryInterval: TimeSpan.FromSeconds(1)); - } - catch (InvalidOperationException ex) - { - capturedException = ex; - } - - return "Complete"; - }, options: new() { Name = "polling_tool" }); - - Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools([pollingTool]); - - await using var app = Builder.Build(); - app.MapMcp(); - - await app.StartAsync(TestContext.Current.CancellationToken); - - await using var mcpClient = await ConnectAsync(); - - await mcpClient.CallToolAsync("polling_tool", cancellationToken: TestContext.Current.CancellationToken); - - Assert.NotNull(capturedException); - Assert.Contains("stateless", capturedException.Message, StringComparison.OrdinalIgnoreCase); - } - [Fact] public async Task EnablePollingAsync_ThrowsInvalidOperationException_WhenNoEventStreamStoreConfigured() { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs new file mode 100644 index 000000000..e026fff73 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs @@ -0,0 +1,818 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.Text.Json; + +namespace ModelContextProtocol.AspNetCore.Tests; + +public abstract partial class MapMcpTests +{ + private ServerMessageTracker ConfigureExperimentalServer(params Delegate[] tools) + { + var messageTracker = new ServerMessageTracker(); + Builder.Services.AddMcpServer(options => + { + options.ServerInfo = new Implementation { Name = "ExperimentalServer", Version = "1" }; + options.ExperimentalProtocolVersion = "2026-06-XX"; + messageTracker.AddFilters(options.Filters.Message); + }) + .WithHttpTransport(ConfigureStateless) + .WithTools(tools.Select(t => McpServerTool.Create(t))); + return messageTracker; + } + + private ServerMessageTracker ConfigureDefaultServer(params Delegate[] tools) + { + var messageTracker = new ServerMessageTracker(); + Builder.Services.AddMcpServer(options => + { + options.ServerInfo = new Implementation { Name = "DefaultServer", Version = "1" }; + messageTracker.AddFilters(options.Filters.Message); + }) + .WithHttpTransport(ConfigureStateless) + .WithTools(tools.Select(t => McpServerTool.Create(t))); + return messageTracker; + } + + private Task ConnectExperimentalAsync() => + ConnectAsync(configureClient: options => + { + ConfigureMrtrHandlers(options); + options.ExperimentalProtocolVersion = "2026-06-XX"; + }); + + private Task ConnectDefaultAsync() => + ConnectAsync(configureClient: ConfigureMrtrHandlers); + + /// Configures elicitation, sampling, and roots handlers on client options. + private static void ConfigureMrtrHandlers(McpClientOptions options) + { + options.Handlers.ElicitationHandler = (request, ct) => + { + var message = request?.Message ?? ""; + var answer = message.Contains("name", StringComparison.OrdinalIgnoreCase) ? "Alice" + : message.Contains("greet", StringComparison.OrdinalIgnoreCase) ? "Hello" + : "yes"; + + return new ValueTask(new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + ["answer"] = JsonDocument.Parse($"\"{answer}\"").RootElement.Clone() + } + }); + }; + options.Handlers.SamplingHandler = (request, progress, ct) => + { + var prompt = request?.Messages?.LastOrDefault()?.Content + .OfType().FirstOrDefault()?.Text ?? ""; + return new ValueTask(new CreateMessageResult + { + Content = [new TextContentBlock { Text = $"LLM:{prompt}" }], + Model = "test-model" + }); + }; + options.Handlers.RootsHandler = (request, ct) => + { + return new ValueTask(new ListRootsResult + { + Roots = [ + new Root { Uri = "file:///project", Name = "Project" }, + new Root { Uri = "file:///data", Name = "Data" } + ] + }); + }; + } + + // ===================================================================== + // MRTR tests: experimental (native), backcompat (legacy JSON-RPC), and edge cases. + // Each test creates its own server with ExperimentalProtocolVersion enabled. + // ===================================================================== + + [McpServerTool(Name = "mrtr-mixed")] + private static async Task MrtrMixed(McpServer server, RequestContext context, CancellationToken ct) + { + var state = context.Params!.RequestState; + var responses = context.Params!.InputResponses; + + // Round 3 entry: confirmation from round 2 available. Transition to await API. + if (state == "round-2" && responses?.TryGetValue("confirm", out var confirmResponse) == true) + { + var confirmation = confirmResponse.ElicitationResult?.Action ?? "unknown"; + + // Await API: sequential sampling then elicitation + var sampleResult = await server.SampleAsync(new CreateMessageRequestParams + { + Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Write greeting" }] }], + MaxTokens = 100 + }, ct); + var greeting = sampleResult.Content.OfType().FirstOrDefault()?.Text ?? ""; + + var signoffResult = await server.ElicitAsync(new ElicitRequestParams + { + Message = "Sign off as?", + RequestedSchema = new() + }, ct); + var signoff = signoffResult.Action; + + return $"{confirmation}|{greeting}|{signoff}"; + } + + // Round 2 entry: parallel results from round 1 available. + if (state == "round-1" && responses is not null) + { + var name = responses["name"].ElicitationResult?.Content?.FirstOrDefault().Value; + var weather = responses["weather"].SamplingResult?.Content + .OfType().FirstOrDefault()?.Text ?? ""; + var root = responses["roots"].RootsResult?.Roots?.FirstOrDefault()?.Name ?? ""; + + // Exception API: single elicitation with requestState + throw new IncompleteResultException( + inputRequests: new Dictionary + { + ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = $"Confirm {name} in {weather} near {root}?", + RequestedSchema = new() + }) + }, + requestState: "round-2"); + } + + // Round 1: Exception API with 3 PARALLEL input requests + throw new IncompleteResultException( + inputRequests: new Dictionary + { + ["name"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "What is your name?", + RequestedSchema = new() + }), + ["weather"] = InputRequest.ForSampling(new CreateMessageRequestParams + { + Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Describe the weather" }] }], + MaxTokens = 100 + }), + ["roots"] = InputRequest.ForRootsList(new ListRootsRequestParams()) + }, + requestState: "round-1"); + } + + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public async Task Mrtr_MixedExceptionAndAwaitStyle(bool experimentalServer, bool experimentalClient) + { + // Configure server — experimental or default based on parameter. + var messageTracker = experimentalServer + ? ConfigureExperimentalServer(MrtrMixed) + : ConfigureDefaultServer(MrtrMixed); + + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + + // Configure client — experimental or default based on parameter. + Action configureClient = experimentalClient + ? options => { ConfigureMrtrHandlers(options); options.ExperimentalProtocolVersion = "2026-06-XX"; } + : ConfigureMrtrHandlers; + + if (experimentalServer) + { + // Success cases: both exception and await APIs complete. + // Skip stateless — await API requires handler suspension (stateful only). + Assert.SkipWhen(Stateless, "Await-style API requires handler suspension (stateful only)."); + + await using var client = await ConnectAsync(configureClient: configureClient); + + if (experimentalClient) + { + // Both experimental — MRTR end-to-end. + Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion); + } + else + { + // Backcompat — server experimental, client default. Legacy JSON-RPC. + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + } + + var result = await client.CallToolAsync("mrtr-mixed", + cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.True(result.IsError is not true); + var parts = text.Split('|'); + Assert.Equal(3, parts.Length); + + // confirmation from round 2 elicitation + Assert.Equal("accept", parts[0]); + // greeting from await SampleAsync — our test handler returns "LLM:{prompt}" + Assert.StartsWith("LLM:", parts[1]); + // signoff from await ElicitAsync + Assert.Equal("accept", parts[2]); + + if (experimentalClient) + { + messageTracker.AssertMrtrUsed(); + } + else + { + messageTracker.AssertMrtrNotUsed(); + } + } + else if (Stateless) + { + // Stateless + non-experimental: IncompleteResultException cannot be resolved + // (no MRTR and no stateful backcompat). The server returns an error. + await using var client = await ConnectAsync(configureClient: configureClient); + + var ex = await Assert.ThrowsAsync(() => + client.CallToolAsync("mrtr-mixed", + cancellationToken: TestContext.Current.CancellationToken).AsTask()); + + Assert.Equal(McpErrorCode.InternalError, ex.ErrorCode); + Assert.Contains("stateless", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("MRTR", ex.Message); + } + else + { + // Stateful + non-experimental: backcompat resolves IncompleteResultException + // via legacy JSON-RPC requests. The tool completes all 3 rounds. + await using var client = await ConnectAsync(configureClient: configureClient); + + var result = await client.CallToolAsync("mrtr-mixed", + cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.True(result.IsError is not true); + var parts = text.Split('|'); + Assert.Equal(3, parts.Length); + + Assert.Equal("accept", parts[0]); + Assert.StartsWith("LLM:", parts[1]); + Assert.Equal("accept", parts[2]); + + messageTracker.AssertMrtrNotUsed(); + } + } + + [McpServerTool(Name = "mrtr-parallel-await")] + private static async Task MrtrParallelAwait(McpServer server, CancellationToken ct) + { + // Start the first await — succeeds with MRTR (creates exchange) + var elicitTask = server.ElicitAsync(new ElicitRequestParams + { + Message = "Parallel elicit", + RequestedSchema = new() + }, ct); + + // Start the second await — with MRTR, this throws InvalidOperationException + // because MrtrContext only supports one pending exchange at a time. + try + { + var sampleTask = server.SampleAsync(new CreateMessageRequestParams + { + Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Parallel sample" }] }], + MaxTokens = 100 + }, ct); + + // If we get here, both calls succeeded (non-MRTR path) + var sampleResult = await sampleTask; + var elicitResult = await elicitTask; + return $"parallel-ok:{elicitResult.Action}:{sampleResult.Content.OfType().First().Text}"; + } + catch (InvalidOperationException ex) + { + return ex.Message; + } + } + + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public async Task Mrtr_ParallelAwaits(bool experimentalServer, bool experimentalClient) + { + // Parallel awaits work with regular JSON-RPC but fail with MRTR because + // MrtrContext only supports one exchange at a time (TrySetResult gate). + Assert.SkipWhen(Stateless, "Await-style API requires handler suspension (stateful only)."); + + var messageTracker = experimentalServer + ? ConfigureExperimentalServer(MrtrParallelAwait) + : ConfigureDefaultServer(MrtrParallelAwait); + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + + // Configure client — experimental or default based on parameter. + Action configureClient = experimentalClient + ? options => { ConfigureMrtrHandlers(options); options.ExperimentalProtocolVersion = "2026-06-XX"; } + : ConfigureMrtrHandlers; + await using var client = await ConnectAsync(configureClient: configureClient); + + if (experimentalServer && experimentalClient) + { + // Both experimental — MRTR active. Parallel awaits hit the MrtrContext + // concurrency gate and the second call throws InvalidOperationException, + // which the tool catches and returns as text. + Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion); + + var result = await client.CallToolAsync("mrtr-parallel-await", + cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Contains("Concurrent server-to-client requests are not supported", text); + Assert.True(result.IsError is not true); + } + else + { + // Non-MRTR: awaits go through regular JSON-RPC — concurrent calls work. + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + + var result = await client.CallToolAsync("mrtr-parallel-await", + cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.StartsWith("parallel-ok:", text); + Assert.True(result.IsError is not true); + } + } + + [McpServerTool(Name = "mrtr-elicit")] + private static string MrtrElicit(RequestContext context) + { + if (context.Params!.InputResponses is { } responses && + responses.TryGetValue("user_input", out var response)) + { + return $"elicit-ok:{response.ElicitationResult?.Action}"; + } + + throw new IncompleteResultException( + inputRequests: new Dictionary + { + ["user_input"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "Please confirm", + RequestedSchema = new() + }) + }, + requestState: "elicit-state"); + } + + [Fact] + public async Task Mrtr_LowLevel_Roots_CompletesViaMrtr() + { + var messageTracker = ConfigureExperimentalServer( + [McpServerTool(Name = "mrtr-roots")] (RequestContext context) => + { + if (context.Params!.InputResponses is { } responses && + responses.TryGetValue("roots", out var response)) + { + var roots = response.RootsResult?.Roots; + return $"roots-ok:{string.Join(",", roots?.Select(r => r.Uri) ?? [])}"; + } + + throw new IncompleteResultException( + inputRequests: new Dictionary + { + ["roots"] = InputRequest.ForRootsList(new ListRootsRequestParams()) + }, + requestState: "roots-state"); + }); + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + await using var client = await ConnectExperimentalAsync(); + Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion); + + var result = await client.CallToolAsync("mrtr-roots", + cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Equal("roots-ok:file:///project,file:///data", text); + Assert.True(result.IsError is not true); + messageTracker.AssertMrtrUsed(); + } + + [McpServerTool(Name = "mrtr-multi")] + private static string MrtrMulti(RequestContext context) + { + var requestState = context.Params!.RequestState; + var inputResponses = context.Params!.InputResponses; + + if (requestState == "round-2" && inputResponses is not null) + { + var greeting = inputResponses["greeting"].ElicitationResult?.Action; + return $"multi-done:greeting={greeting}"; + } + + if (requestState == "round-1" && inputResponses is not null) + { + var name = inputResponses["name"].ElicitationResult?.Content?.FirstOrDefault().Value; + throw new IncompleteResultException( + inputRequests: new Dictionary + { + ["greeting"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = $"How should I greet {name}?", + RequestedSchema = new() + }) + }, + requestState: "round-2"); + } + + throw new IncompleteResultException( + inputRequests: new Dictionary + { + ["name"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "What is your name?", + RequestedSchema = new() + }) + }, + requestState: "round-1"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Mrtr_MultiRoundTrip_Completes(bool experimentalClient) + { + var messageTracker = ConfigureExperimentalServer(MrtrMulti); + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + + // Configure client — experimental or default based on parameter. + Action configureClient = experimentalClient + ? options => { ConfigureMrtrHandlers(options); options.ExperimentalProtocolVersion = "2026-06-XX"; } + : ConfigureMrtrHandlers; + await using var client = await ConnectAsync(configureClient: configureClient); + + if (!experimentalClient && Stateless) + { + // Stateless without MRTR: IncompleteResultException can't be resolved + // (no MRTR negotiated and no stateful backcompat path). + var ex = await Assert.ThrowsAsync(() => + client.CallToolAsync("mrtr-multi", + cancellationToken: TestContext.Current.CancellationToken).AsTask()); + Assert.Equal(McpErrorCode.InternalError, ex.ErrorCode); + return; + } + + var result = await client.CallToolAsync("mrtr-multi", + cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Equal("multi-done:greeting=accept", text); + Assert.True(result.IsError is not true); + + if (experimentalClient) + { + Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion); + messageTracker.AssertMrtrUsed(); + } + else + { + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + messageTracker.AssertMrtrNotUsed(); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Mrtr_IsMrtrSupported(bool experimentalClient) + { + ConfigureExperimentalServer([McpServerTool(Name = "mrtr-check")] (McpServer server) => server.IsMrtrSupported.ToString()); + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + + // Configure client — experimental or default based on parameter. + Action configureClient = experimentalClient + ? options => { ConfigureMrtrHandlers(options); options.ExperimentalProtocolVersion = "2026-06-XX"; } + : ConfigureMrtrHandlers; + await using var client = await ConnectAsync(configureClient: configureClient); + Assert.Equal(experimentalClient ? "2026-06-XX" : "2025-11-25", client.NegotiatedProtocolVersion); + + var result = await client.CallToolAsync("mrtr-check", + cancellationToken: TestContext.Current.CancellationToken); + + // IsMrtrSupported is false only when stateless AND client didn't negotiate MRTR + // (no backcompat path available). All other combos have MRTR or backcompat support. + var expected = Stateless && !experimentalClient ? "False" : "True"; + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Equal(expected, text); + } + + [McpServerTool(Name = "mrtr-concurrent-three")] + private static string MrtrConcurrentThree(RequestContext context) + { + if (context.Params!.InputResponses is { Count: 3 } responses && + responses.ContainsKey("elicit") && + responses.ContainsKey("sample") && + responses.ContainsKey("roots")) + { + var elicitAction = responses["elicit"].ElicitationResult?.Action; + var sampleText = responses["sample"].SamplingResult? + .Content.OfType().FirstOrDefault()?.Text; + var rootUris = string.Join(",", + responses["roots"].RootsResult?.Roots.Select(r => r.Uri) ?? []); + return $"all-ok:elicit={elicitAction},sample={sampleText},roots={rootUris}"; + } + + throw new IncompleteResultException( + inputRequests: new Dictionary + { + ["elicit"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "Confirm action", + RequestedSchema = new() + }), + ["sample"] = InputRequest.ForSampling(new CreateMessageRequestParams + { + Messages = [new SamplingMessage + { + Role = Role.User, + Content = [new TextContentBlock { Text = "Generate summary" }] + }], + MaxTokens = 50 + }), + ["roots"] = InputRequest.ForRootsList(new ListRootsRequestParams()) + }, + requestState: "concurrent-state"); + } + + [Fact] + public async Task Mrtr_ConcurrentThreeInputs_ResolvedSimultaneously() + { + var messageTracker = ConfigureExperimentalServer(MrtrConcurrentThree); + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + + var elicitCalled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var samplingCalled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var rootsCalled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + await using var client = await ConnectAsync(configureClient: options => + { + options.ExperimentalProtocolVersion = "2026-06-XX"; + options.Handlers.ElicitationHandler = async (request, ct) => + { + elicitCalled.TrySetResult(); + await Task.WhenAll(samplingCalled.Task.WaitAsync(ct), rootsCalled.Task.WaitAsync(ct)); + return new ElicitResult { Action = "accept" }; + }; + options.Handlers.SamplingHandler = async (request, progress, ct) => + { + samplingCalled.TrySetResult(); + await Task.WhenAll(elicitCalled.Task.WaitAsync(ct), rootsCalled.Task.WaitAsync(ct)); + return new CreateMessageResult + { + Content = [new TextContentBlock { Text = "AI-summary" }], + Model = "test-model" + }; + }; + options.Handlers.RootsHandler = async (request, ct) => + { + rootsCalled.TrySetResult(); + await Task.WhenAll(elicitCalled.Task.WaitAsync(ct), samplingCalled.Task.WaitAsync(ct)); + return new ListRootsResult + { + Roots = [new Root { Uri = "file:///workspace", Name = "Workspace" }] + }; + }; + }); + Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion); + + var result = await client.CallToolAsync("mrtr-concurrent-three", + cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Equal("all-ok:elicit=accept,sample=AI-summary,roots=file:///workspace", text); + Assert.True(result.IsError is not true); + messageTracker.AssertMrtrUsed(); + } + + [Fact] + public async Task Mrtr_Experimental_LoadShedding_RequestStateOnly_CompletesViaMrtr() + { + var messageTracker = ConfigureExperimentalServer( + [McpServerTool(Name = "mrtr-loadshed")] (RequestContext context) => + { + if (context.Params!.RequestState is { } state) + { + return $"resumed:{state}"; + } + + // requestState-only IncompleteResultException (no inputRequests) + throw new IncompleteResultException(requestState: "deferred-work"); + }); + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + await using var client = await ConnectExperimentalAsync(); + Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion); + + var result = await client.CallToolAsync("mrtr-loadshed", + cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Equal("resumed:deferred-work", text); + Assert.True(result.IsError is not true); + messageTracker.AssertMrtrUsed(); + } + + [Fact] + public async Task Mrtr_Backcompat_Roots_ResolvedViaLegacyJsonRpc() + { + Assert.SkipWhen(Stateless, "Backcompat requires stateful server for legacy JSON-RPC."); + var messageTracker = ConfigureExperimentalServer( + [McpServerTool(Name = "mrtr-roots-backcompat")] (RequestContext context) => + { + if (context.Params!.InputResponses is { } responses && + responses.TryGetValue("roots", out var response)) + { + var roots = response.RootsResult?.Roots; + return $"roots-ok:{roots?.FirstOrDefault()?.Name}"; + } + + throw new IncompleteResultException( + inputRequests: new Dictionary + { + ["roots"] = InputRequest.ForRootsList(new ListRootsRequestParams()) + }, + requestState: "roots-state"); + }); + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + await using var client = await ConnectDefaultAsync(); + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + + var result = await client.CallToolAsync("mrtr-roots-backcompat", + cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Equal("roots-ok:Project", text); + Assert.True(result.IsError is not true); + messageTracker.AssertMrtrNotUsed(); + } + + [Fact] + public async Task Mrtr_Backcompat_MultipleInputRequests_ResolvedViaLegacyJsonRpc() + { + Assert.SkipWhen(Stateless, "Backcompat requires stateful server for legacy JSON-RPC."); + var messageTracker = ConfigureExperimentalServer( + [McpServerTool(Name = "mrtr-multi-input")] (RequestContext context) => + { + if (context.Params!.InputResponses is { } responses && + responses.TryGetValue("confirm", out var elicitResponse) && + responses.TryGetValue("summarize", out var sampleResponse)) + { + var action = elicitResponse.ElicitationResult?.Action; + var text = sampleResponse.SamplingResult?.Content.OfType().FirstOrDefault()?.Text; + return $"both:{action}:{text}"; + } + + throw new IncompleteResultException( + inputRequests: new Dictionary + { + ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "Please confirm", + RequestedSchema = new() + }), + ["summarize"] = InputRequest.ForSampling(new CreateMessageRequestParams + { + Messages = [new SamplingMessage + { + Role = Role.User, + Content = [new TextContentBlock { Text = "Summarize" }] + }], + MaxTokens = 100 + }) + }, + requestState: "multi-input-state"); + }); + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + await using var client = await ConnectDefaultAsync(); + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + + var result = await client.CallToolAsync("mrtr-multi-input", + cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Equal("both:accept:LLM:Summarize", text); + Assert.True(result.IsError is not true); + messageTracker.AssertMrtrNotUsed(); + } + + [Fact] + public async Task Mrtr_Backcompat_AlwaysIncomplete_FailsAfterMaxRetries() + { + Assert.SkipWhen(Stateless, "Backcompat requires stateful server for legacy JSON-RPC."); + int elicitCallCount = 0; + + ConfigureExperimentalServer( + [McpServerTool(Name = "mrtr-always-incomplete")] (RequestContext context) => + { + // Always throw — never complete + throw new IncompleteResultException( + inputRequests: new Dictionary + { + ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "Confirm again", + RequestedSchema = new() + }) + }, + requestState: "infinite"); + }); + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + await using var client = await ConnectAsync(configureClient: options => + { + ConfigureMrtrHandlers(options); + var originalHandler = options.Handlers.ElicitationHandler!; + options.Handlers.ElicitationHandler = (request, ct) => + { + Interlocked.Increment(ref elicitCallCount); + return originalHandler(request, ct); + }; + }); + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + + var ex = await Assert.ThrowsAsync(() => + client.CallToolAsync("mrtr-always-incomplete", + cancellationToken: TestContext.Current.CancellationToken).AsTask()); + + Assert.Contains("exceeded", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("10", ex.Message); + Assert.Equal(10, elicitCallCount); + } + + [Fact] + public async Task Mrtr_Backcompat_EmptyInputRequests_FailsWithError() + { + Assert.SkipWhen(Stateless, "Backcompat requires stateful server for legacy JSON-RPC."); + ConfigureExperimentalServer( + [McpServerTool(Name = "mrtr-empty-inputs")] (RequestContext context) => + { + throw new IncompleteResultException( + inputRequests: new Dictionary(), + requestState: "empty"); + }); + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + await using var client = await ConnectDefaultAsync(); + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + + var ex = await Assert.ThrowsAsync(() => + client.CallToolAsync("mrtr-empty-inputs", + cancellationToken: TestContext.Current.CancellationToken).AsTask()); + + Assert.Contains("without input requests", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(McpErrorCode.InternalError, ex.ErrorCode); + } + + [Fact] + public async Task Mrtr_Backcompat_ClientHandlerThrows_PropagatesError() + { + Assert.SkipWhen(Stateless, "Backcompat requires stateful server for legacy JSON-RPC."); + + ConfigureExperimentalServer(MrtrElicit); + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + await using var client = await ConnectAsync(configureClient: options => + { + ConfigureMrtrHandlers(options); + options.Handlers.ElicitationHandler = (request, ct) => + { + throw new InvalidOperationException("Client-side elicitation failure"); + }; + }); + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + + // Handler exception propagates through the backcompat JSON-RPC round-trip. + // The original exception message gets wrapped in "Request failed (remote)" during backcompat. + var ex = await Assert.ThrowsAsync(() => + client.CallToolAsync("mrtr-elicit", + cancellationToken: TestContext.Current.CancellationToken).AsTask()); + Assert.Equal(McpErrorCode.InternalError, ex.ErrorCode); + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs index 678b27022..bde13f5c3 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs @@ -14,7 +14,7 @@ namespace ModelContextProtocol.AspNetCore.Tests; -public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTest(testOutputHelper) +public abstract partial class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelInMemoryTest(testOutputHelper) { protected abstract bool UseStreamableHttp { get; } protected abstract bool Stateless { get; } @@ -27,9 +27,8 @@ protected virtual void ConfigureStateless(HttpServerTransportOptions options) protected async Task ConnectAsync( string? path = null, HttpClientTransportOptions? transportOptions = null, - McpClientOptions? clientOptions = null) + Action? configureClient = null) { - // Default behavior when no options are provided path ??= UseStreamableHttp ? "/" : "/sse"; await using var transport = new HttpClientTransport(transportOptions ?? new HttpClientTransportOptions @@ -38,6 +37,8 @@ protected async Task ConnectAsync( TransportMode = UseStreamableHttp ? HttpTransportMode.StreamableHttp : HttpTransportMode.Sse, }, HttpClient, LoggerFactory); + var clientOptions = new McpClientOptions(); + configureClient?.Invoke(clientOptions); return await McpClient.CreateAsync(transport, clientOptions, LoggerFactory, TestContext.Current.CancellationToken); } @@ -156,29 +157,24 @@ public async Task Sampling_DoesNotCloseStreamPrematurely() await app.StartAsync(TestContext.Current.CancellationToken); var sampleCount = 0; - var clientOptions = new McpClientOptions() + await using var mcpClient = await ConnectAsync(configureClient: options => { - Handlers = new() + options.Handlers.SamplingHandler = async (parameters, _, _) => { - SamplingHandler = async (parameters, _, _) => - { - Assert.NotNull(parameters?.Messages); - var message = Assert.Single(parameters.Messages); - Assert.Equal(Role.User, message.Role); - Assert.Equal("Test prompt for sampling", Assert.IsType(Assert.Single(message.Content)).Text); + Assert.NotNull(parameters?.Messages); + var message = Assert.Single(parameters.Messages); + Assert.Equal(Role.User, message.Role); + Assert.Equal("Test prompt for sampling", Assert.IsType(Assert.Single(message.Content)).Text); - sampleCount++; - return new CreateMessageResult - { - Model = "test-model", - Role = Role.Assistant, - Content = [new TextContentBlock { Text = "Sampling response from client" }], - }; - } - } - }; - - await using var mcpClient = await ConnectAsync(clientOptions: clientOptions); + sampleCount++; + return new CreateMessageResult + { + Model = "test-model", + Role = Role.Assistant, + Content = [new TextContentBlock { Text = "Sampling response from client" }], + }; + }; + }); var result = await mcpClient.CallToolAsync("sampling-tool", new Dictionary { @@ -292,6 +288,7 @@ public async Task LongRunningToolCall_DoesNotTimeout_WhenNoEventStreamStore() } + [Fact] public async Task IncomingFilter_SeesClientRequests() { @@ -375,7 +372,11 @@ public async Task OutgoingFilter_SeesResponsesAndRequests() }, }; - await using var client = await ConnectAsync(clientOptions: clientOptions); + await using var client = await ConnectAsync(configureClient: opts => + { + opts.Capabilities = clientOptions.Capabilities; + opts.Handlers = clientOptions.Handlers; + }); await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); await client.CallToolAsync("echo_claims_principal", @@ -496,6 +497,7 @@ public async Task OutgoingFilter_CanSendAdditionalMessages() Assert.Equal("injected", extraMessage); } + private ClaimsPrincipal CreateUser(string name) => new(new ClaimsIdentity( [new Claim("name", name), new Claim(ClaimTypes.NameIdentifier, name)], @@ -566,4 +568,5 @@ public static async Task LongRunningOperation( return $"Operation completed after {durationMs}ms"; } } + } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs new file mode 100644 index 000000000..f1239e504 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs @@ -0,0 +1,289 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Net; +using System.Net.ServerSentEvents; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; + +namespace ModelContextProtocol.AspNetCore.Tests; + +/// +/// Protocol-level tests for Multi Round-Trip Requests (MRTR). +/// These tests send raw JSON-RPC requests via HTTP and verify protocol-level behavior +/// including IncompleteResult structure, retry with inputResponses, and error handling. +/// +public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable +{ + private WebApplication? _app; + + private async Task StartAsync() + { + Builder.Services.AddMcpServer(options => + { + options.ServerInfo = new Implementation + { + Name = nameof(MrtrProtocolTests), + Version = "1", + }; + options.ExperimentalProtocolVersion = "2026-06-XX"; + }).WithTools([ + McpServerTool.Create( + async (string message, McpServer server, CancellationToken ct) => + { + var result = await server.ElicitAsync(new ElicitRequestParams + { + Message = message, + RequestedSchema = new() + }, ct); + + return $"{result.Action}:{result.Content?.FirstOrDefault().Value}"; + }, + new McpServerToolCreateOptions + { + Name = "elicit-tool", + Description = "Elicits from client" + }), + McpServerTool.Create( + static string (McpServer _) => throw new McpProtocolException("Tool validation failed", McpErrorCode.InvalidParams), + new McpServerToolCreateOptions + { + Name = "throwing-tool", + Description = "A tool that throws immediately" + }), + ]).WithHttpTransport(); + + _app = Builder.Build(); + _app.MapMcp(); + await _app.StartAsync(TestContext.Current.CancellationToken); + + HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); + HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); + } + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.DisposeAsync(); + } + base.Dispose(); + } + + [Fact] + public async Task ToolThatThrows_ReturnsJsonRpcError_NotIncompleteResult() + { + await StartAsync(); + await InitializeWithMrtrAsync(); + + var response = await PostJsonRpcAsync(CallTool("throwing-tool")); + + // Should be a JSON-RPC error, not an IncompleteResult + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var sseData = Assert.Single(await ReadSseAsync(response.Content).ToListAsync(TestContext.Current.CancellationToken)); + var message = JsonSerializer.Deserialize(sseData, McpJsonUtilities.DefaultOptions); + var error = Assert.IsType(message); + Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); + Assert.Contains("Tool validation failed", error.Error.Message); + } + + [Fact] + public async Task RetryWithInvalidRequestState_ReturnsJsonRpcError() + { + await StartAsync(); + await InitializeWithMrtrAsync(); + + // Send a retry with a requestState that doesn't match any active continuation + var retryParams = new JsonObject + { + ["name"] = "elicit-tool", + ["arguments"] = new JsonObject { ["message"] = "test" }, + ["inputResponses"] = new JsonObject { ["key1"] = new JsonObject { ["action"] = "confirm" } }, + ["requestState"] = "nonexistent-state-id" + }; + + var response = await PostJsonRpcAsync(Request("tools/call", retryParams.ToJsonString())); + + // Read as a generic JsonRpcMessage to check if it's an error + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var sseData = Assert.Single(await ReadSseAsync(response.Content).ToListAsync(TestContext.Current.CancellationToken)); + var message = JsonSerializer.Deserialize(sseData, McpJsonUtilities.DefaultOptions); + + // Invalid requestState should result in a fresh tool invocation + // (the tool will return IncompleteResult since it calls ElicitAsync) + // or an error, depending on the implementation. + // In our implementation, unrecognized requestState triggers a new invocation. + Assert.True( + message is JsonRpcResponse or JsonRpcError, + $"Expected JsonRpcResponse or JsonRpcError, got {message?.GetType().Name}"); + } + + [Fact] + public async Task SessionDelete_CancelsPendingMrtrContinuation() + { + await StartAsync(); + await InitializeWithMrtrAsync(); + + // 1. Call a tool that suspends at ElicitAsync (high-level MRTR path). + var response = await PostJsonRpcAsync(CallTool("elicit-tool", """{"message":"Please confirm"}""")); + var rpcResponse = await AssertSingleSseResponseAsync(response); + + // Verify we got an IncompleteResult (handler is now suspended, continuation stored). + var resultObj = Assert.IsType(rpcResponse.Result); + Assert.Equal("incomplete", resultObj["result_type"]?.GetValue()); + var requestState = resultObj["requestState"]!.GetValue(); + Assert.False(string.IsNullOrEmpty(requestState)); + + // 2. DELETE the session while the handler is suspended. + using var deleteResponse = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode); + + // Poll for the async cancellation to propagate through the handler task. + // Under thread pool starvation, this can take significantly longer than 100ms. + var deadline = DateTime.UtcNow.AddSeconds(30); + while (true) + { + if (MockLoggerProvider.LogMessages.Any(m => m.Message.Contains("pending MRTR continuation")) + || DateTime.UtcNow >= deadline) + { + break; + } + + await Task.Delay(100, TestContext.Current.CancellationToken); + } + + // 3. Verify that the MRTR cancellation was logged at Debug level. + var mrtrCancelledLog = MockLoggerProvider.LogMessages + .Where(m => m.Message.Contains("pending MRTR continuation")) + .ToList(); + Assert.Single(mrtrCancelledLog); + Assert.Equal(LogLevel.Debug, mrtrCancelledLog[0].LogLevel); + Assert.Contains("1", mrtrCancelledLog[0].Message); + + // 4. Verify no error-level log was emitted for the cancellation. + // The handler's OperationCanceledException should be silently observed, not logged as an error. + var errorLogs = MockLoggerProvider.LogMessages + .Where(m => m.LogLevel >= LogLevel.Error && m.Message.Contains("elicit")) + .ToList(); + Assert.Empty(errorLogs); + } + + [Fact] + public async Task SessionDelete_RetryAfterDelete_ReturnsSessionNotFound() + { + await StartAsync(); + await InitializeWithMrtrAsync(); + + // 1. Call a tool that suspends at ElicitAsync. + var response = await PostJsonRpcAsync(CallTool("elicit-tool", """{"message":"Please confirm"}""")); + var rpcResponse = await AssertSingleSseResponseAsync(response); + + var resultObj = Assert.IsType(rpcResponse.Result); + var requestState = resultObj["requestState"]!.GetValue(); + var inputRequests = resultObj["inputRequests"]!.AsObject(); + var inputKey = inputRequests.First().Key; + + // 2. DELETE the session. + using var deleteResponse = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode); + + // 3. Attempt to retry with the old requestState — session is gone. + var inputResponse = InputResponse.FromElicitResult(new ElicitResult { Action = "accept" }); + var retryParams = new JsonObject + { + ["name"] = "elicit-tool", + ["arguments"] = new JsonObject { ["message"] = "Please confirm" }, + ["requestState"] = requestState, + ["inputResponses"] = new JsonObject + { + [inputKey] = JsonSerializer.SerializeToNode(inputResponse, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(InputResponse))) + }, + }; + + using var retryResponse = await PostJsonRpcAsync(Request("tools/call", retryParams.ToJsonString())); + + // The session was deleted, so we should get a 404 with a JSON-RPC error. + Assert.Equal(HttpStatusCode.NotFound, retryResponse.StatusCode); + Assert.Equal("application/json", retryResponse.Content.Headers.ContentType?.MediaType); + } + + // --- Helpers --- + + private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json"); + private static JsonTypeInfo GetJsonTypeInfo() => (JsonTypeInfo)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T)); + + private static async IAsyncEnumerable ReadSseAsync(HttpContent responseContent) + { + var responseStream = await responseContent.ReadAsStreamAsync(TestContext.Current.CancellationToken); + await foreach (var sseItem in SseParser.Create(responseStream).EnumerateAsync(TestContext.Current.CancellationToken)) + { + Assert.Equal("message", sseItem.EventType); + yield return sseItem.Data; + } + } + + private static async Task AssertSingleSseResponseAsync(HttpResponseMessage response) + { + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("text/event-stream", response.Content.Headers.ContentType?.MediaType); + + var sseItem = Assert.Single(await ReadSseAsync(response.Content).ToListAsync(TestContext.Current.CancellationToken)); + var jsonRpcResponse = JsonSerializer.Deserialize(sseItem, GetJsonTypeInfo()); + + Assert.NotNull(jsonRpcResponse); + return jsonRpcResponse; + } + + private Task PostJsonRpcAsync(string json) => + HttpClient.PostAsync("", JsonContent(json), TestContext.Current.CancellationToken); + + private long _lastRequestId = 1; + + private string Request(string method, string parameters = "{}") + { + var id = Interlocked.Increment(ref _lastRequestId); + return $$""" + {"jsonrpc":"2.0","id":{{id}},"method":"{{method}}","params":{{parameters}}} + """; + } + + private string CallTool(string toolName, string arguments = "{}") => + Request("tools/call", $$""" + {"name":"{{toolName}}","arguments":{{arguments}}} + """); + + /// + /// Initialize a session requesting the experimental protocol version that enables MRTR. + /// + private async Task InitializeWithMrtrAsync() + { + var initJson = """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-06-XX","capabilities":{"sampling":{},"elicitation":{},"roots":{}},"clientInfo":{"name":"MrtrTestClient","version":"1.0.0"}}} + """; + + using var response = await PostJsonRpcAsync(initJson); + var rpcResponse = await AssertSingleSseResponseAsync(response); + Assert.NotNull(rpcResponse.Result); + + // Verify the server negotiated to the experimental version + var protocolVersion = rpcResponse.Result["protocolVersion"]?.GetValue(); + Assert.Equal("2026-06-XX", protocolVersion); + + var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id")); + HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); + HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId); + + // Set the MCP-Protocol-Version header for subsequent requests + HttpClient.DefaultRequestHeaders.Remove("MCP-Protocol-Version"); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "2026-06-XX"); + + // Reset request ID counter since initialize used ID 1 + _lastRequestId = 1; + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientDeferredTaskCreationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientDeferredTaskCreationTests.cs new file mode 100644 index 000000000..f21f3603b --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/McpClientDeferredTaskCreationTests.cs @@ -0,0 +1,334 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.ComponentModel; +using System.Text.Json; + +namespace ModelContextProtocol.Tests.Client; + +/// +/// Tests for deferred task creation, where a tool performs ephemeral MRTR exchanges +/// before committing to a background task via . +/// +public class McpClientDeferredTaskCreationTests : ClientServerTestBase +{ + private readonly TaskCompletionSource _toolAfterTaskCreation = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly InMemoryMcpTaskStore _taskStore = new(); + + public McpClientDeferredTaskCreationTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + services.AddSingleton(_taskStore); + services.Configure(options => + { + options.TaskStore = _taskStore; + options.ExperimentalProtocolVersion = "2026-06-XX"; + }); + + mcpServerBuilder.WithTools() + .WithTools([ + // Tool that elicits before creating a task, then does work in background. + McpServerTool.Create( + async (string vmName, McpServer server, CancellationToken ct) => + { + // Phase 1: Ephemeral MRTR — confirm with user before starting expensive work. + var confirmation = await server.ElicitAsync(new ElicitRequestParams + { + Message = $"Provision VM '{vmName}'? This will incur costs.", + RequestedSchema = new() + }, ct); + + if (confirmation.Action != "confirm") + { + return "Cancelled by user."; + } + + // Phase 2: Transition to task. + await server.CreateTaskAsync(ct); + _toolAfterTaskCreation.TrySetResult(true); + + // Phase 3: Background work (simulated). + await Task.Delay(50, ct); + return $"VM '{vmName}' provisioned successfully."; + }, + new McpServerToolCreateOptions + { + Name = "provision-vm", + Description = "Provisions a VM with user confirmation", + DeferTaskCreation = true, + Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional }, + }), + + // Tool that does MRTR but returns without creating a task. + McpServerTool.Create( + async (string question, McpServer server, CancellationToken ct) => + { + var result = await server.ElicitAsync(new ElicitRequestParams + { + Message = question, + RequestedSchema = new() + }, ct); + + return $"Answer: {result.Action}"; + }, + new McpServerToolCreateOptions + { + Name = "ask-question", + Description = "Asks a question and returns the answer without creating a task", + DeferTaskCreation = true, + Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional }, + }), + + // Tool that does NOT have DeferTaskCreation — existing behavior. + McpServerTool.Create( + async (string input, CancellationToken ct) => + { + await Task.Delay(50, ct); + return $"Processed: {input}"; + }, + new McpServerToolCreateOptions + { + Name = "immediate-task-tool", + Description = "A task tool with immediate task creation (default)", + Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional }, + }), + + // Tool that does multiple MRTR rounds, then creates a task. + McpServerTool.Create( + async (McpServer server, CancellationToken ct) => + { + // Round 1: Ask for name. + var nameResult = await server.ElicitAsync(new ElicitRequestParams + { + Message = "What is your name?", + RequestedSchema = new() + }, ct); + + // Round 2: Ask for email. + var emailResult = await server.ElicitAsync(new ElicitRequestParams + { + Message = "What is your email?", + RequestedSchema = new() + }, ct); + + // Transition to task after gathering all input. + await server.CreateTaskAsync(ct); + + await Task.Delay(50, ct); + return $"Registered: {nameResult.Action}, {emailResult.Action}"; + }, + new McpServerToolCreateOptions + { + Name = "multi-round-then-task", + Description = "Does multiple MRTR rounds then creates a task", + DeferTaskCreation = true, + Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional }, + }), + ]); + } + + private static McpClientHandlers CreateElicitationHandlers() + { + return new McpClientHandlers + { + ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult + { + Action = "confirm", + Content = new Dictionary() + }) + }; + } + + private async Task CallToolWithTaskMetadataAsync( + McpClient client, string toolName, Dictionary? arguments = null) + { + var requestParams = new CallToolRequestParams + { + Name = toolName, + Task = new McpTaskMetadata(), + }; + + if (arguments is not null) + { + requestParams.Arguments = arguments.ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value is not null + ? JsonSerializer.SerializeToElement(kvp.Value, McpJsonUtilities.DefaultOptions) + : default); + } + + return await client.CallToolAsync(requestParams, TestContext.Current.CancellationToken); + } + + private McpClientOptions CreateClientOptions(McpClientHandlers? handlers = null) + { + return new McpClientOptions + { + ExperimentalProtocolVersion = "2026-06-XX", + TaskStore = _taskStore, + Handlers = handlers ?? CreateElicitationHandlers() + }; + } + + private async Task WaitForTaskCompletionAsync(string taskId) + { + McpTask? taskStatus; + do + { + await Task.Delay(100, TestContext.Current.CancellationToken); + taskStatus = await _taskStore.GetTaskAsync(taskId, cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(taskStatus); + } + while (taskStatus.Status is McpTaskStatus.Working or McpTaskStatus.InputRequired); + + return taskStatus; + } + + [Fact] + public async Task DeferredTaskCreation_ElicitThenCreateTask_ReturnsTaskResult() + { + StartServer(); + await using var client = await CreateMcpClientForServer(CreateClientOptions()); + + var result = await CallToolWithTaskMetadataAsync(client, "provision-vm", + new Dictionary { ["vmName"] = "test-vm" }); + + // The result should have a task (created after MRTR elicitation). + Assert.NotNull(result.Task); + Assert.NotEmpty(result.Task.TaskId); + + // Wait for the tool to finish in the background. + await _toolAfterTaskCreation.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); + var taskStatus = await WaitForTaskCompletionAsync(result.Task.TaskId); + Assert.Equal(McpTaskStatus.Completed, taskStatus.Status); + } + + [Fact] + public async Task DeferredTaskCreation_ElicitWithoutCreatingTask_ReturnsNormalResult() + { + StartServer(); + await using var client = await CreateMcpClientForServer(CreateClientOptions()); + + var result = await CallToolWithTaskMetadataAsync(client, "ask-question", + new Dictionary { ["question"] = "How are you?" }); + + // Tool returned without calling CreateTaskAsync — normal result, no task. + Assert.Null(result.Task); + var content = Assert.Single(result.Content); + Assert.Equal("Answer: confirm", Assert.IsType(content).Text); + } + + [Fact] + public async Task DeferredTaskCreation_WithoutTaskMetadata_NormalExecution() + { + StartServer(); + await using var client = await CreateMcpClientForServer(CreateClientOptions()); + + // Call without task metadata — the tool does MRTR normally, no task involved. + var result = await client.CallToolAsync("ask-question", + new Dictionary { ["question"] = "No task" }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Null(result.Task); + Assert.Equal("Answer: confirm", Assert.IsType(Assert.Single(result.Content)).Text); + } + + [Fact] + public async Task DeferredTaskCreation_MultipleRoundsThenCreateTask_AllRoundsComplete() + { + StartServer(); + var elicitCount = 0; + var handlers = new McpClientHandlers + { + ElicitationHandler = (request, ct) => + { + var count = Interlocked.Increment(ref elicitCount); + var value = count == 1 ? "Alice" : "alice@example.com"; + return new ValueTask(new ElicitResult + { + Action = value, + Content = new Dictionary() + }); + } + }; + + await using var client = await CreateMcpClientForServer(CreateClientOptions(handlers)); + + var result = await CallToolWithTaskMetadataAsync(client, "multi-round-then-task"); + + // Should have created a task after two MRTR rounds. + Assert.NotNull(result.Task); + Assert.Equal(2, elicitCount); + + var taskStatus = await WaitForTaskCompletionAsync(result.Task.TaskId); + Assert.Equal(McpTaskStatus.Completed, taskStatus.Status); + } + + [Fact] + public async Task BackwardsCompat_ImmediateTaskCreation_WorksUnchanged() + { + StartServer(); + await using var client = await CreateMcpClientForServer(CreateClientOptions(new McpClientHandlers())); + + var result = await CallToolWithTaskMetadataAsync(client, "immediate-task-tool", + new Dictionary { ["input"] = "test" }); + + // Immediate task creation — result has task immediately. + Assert.NotNull(result.Task); + + var taskStatus = await WaitForTaskCompletionAsync(result.Task.TaskId); + Assert.Equal(McpTaskStatus.Completed, taskStatus.Status); + } + + [Fact] + public async Task DeferredTaskCreation_AttributeBased_ElicitThenCreateTask() + { + StartServer(); + await using var client = await CreateMcpClientForServer(CreateClientOptions()); + + var result = await CallToolWithTaskMetadataAsync(client, "provision_vm", + new Dictionary { ["vmName"] = "test-vm" }); + + // The attribute-based tool should create a task after MRTR elicitation. + Assert.NotNull(result.Task); + Assert.NotEmpty(result.Task.TaskId); + + var taskStatus = await WaitForTaskCompletionAsync(result.Task.TaskId); + Assert.Equal(McpTaskStatus.Completed, taskStatus.Status); + } + + /// + /// Attribute-based tool type demonstrating deferred task creation. + /// Matches the pattern shown in the MRTR conceptual documentation. + /// + [McpServerToolType] + private sealed class DeferredTaskToolType + { + [McpServerTool(DeferTaskCreation = true, TaskSupport = ToolTaskSupport.Optional)] + [Description("Provisions a VM with user confirmation")] + public static async Task ProvisionVm( + string vmName, McpServer server, CancellationToken ct) + { + var confirmation = await server.ElicitAsync(new ElicitRequestParams + { + Message = $"Provision VM '{vmName}'? This will incur costs.", + RequestedSchema = new() + }, ct); + + if (confirmation.Action != "confirm") + return "Cancelled by user."; + + await server.CreateTaskAsync(ct); + + await Task.Delay(50, ct); + return $"VM '{vmName}' provisioned successfully."; + } + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs index 262efbd40..e611bdc4e 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs @@ -587,6 +587,14 @@ public async Task ReturnsNegotiatedProtocolVersion(string? protocolVersion) Assert.Equal(protocolVersion ?? "2025-11-25", client.NegotiatedProtocolVersion); } + [Fact] + public async Task ReturnsNegotiatedProtocolVersion_WithExperimentalProtocol() + { + Server.ServerOptions.ExperimentalProtocolVersion = "2026-06-XX"; + await using McpClient client = await CreateMcpClientForServer(new() { ExperimentalProtocolVersion = "2026-06-XX" }); + Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion); + } + [Fact] public async Task EndToEnd_SamplingWithTools_ServerUsesIChatClientWithFunctionInvocation_ClientHandlesSamplingWithIChatClient() { diff --git a/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs new file mode 100644 index 000000000..100412096 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs @@ -0,0 +1,621 @@ +#if !NET472 +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.IO.Pipelines; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Client; + +/// +/// Edge-case and guardrail tests for MRTR over in-memory pipe transport. These focus on +/// scenarios not easily covered by +/// which provides broad happy-path coverage across StreamableHttp, SSE, and Stateless transports. +/// +public class MrtrIntegrationTests : ClientServerTestBase +{ + private readonly ServerMessageTracker _messageTracker = new(); + + public MrtrIntegrationTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug)); + services.Configure(options => + { + options.ExperimentalProtocolVersion = "2026-06-XX"; + _messageTracker.AddFilters(options.Filters.Message); + }); + + mcpServerBuilder.WithTools([ + McpServerTool.Create( + async (string message, McpServer server, CancellationToken ct) => + { + var result = await server.ElicitAsync(new ElicitRequestParams + { + Message = message, + RequestedSchema = new() + }, ct); + + return $"{result.Action}:{result.Content?.FirstOrDefault().Value}"; + }, + new McpServerToolCreateOptions + { + Name = "elicitation-tool", + Description = "A tool that requests elicitation from the client" + }), + McpServerTool.Create( + async (McpServer server, CancellationToken ct) => + { + // Attempt concurrent ElicitAsync + SampleAsync — MrtrContext prevents this. + var t1 = server.ElicitAsync(new ElicitRequestParams + { + Message = "Concurrent elicit", + RequestedSchema = new() + }, ct).AsTask(); + + var t2 = server.SampleAsync(new CreateMessageRequestParams + { + Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Concurrent sample" }] }], + MaxTokens = 100 + }, ct).AsTask(); + + await Task.WhenAll(t1, t2); + return "done"; + }, + new McpServerToolCreateOptions + { + Name = "concurrent-tool", + Description = "A tool that attempts concurrent elicitation and sampling" + }), + McpServerTool.Create( + (McpServer server) => + { + // Low-level MRTR: throw IncompleteResultException directly instead of using ElicitAsync. + // This should NOT be logged at Error level — it's normal MRTR control flow. + throw new IncompleteResultException(new IncompleteResult + { + InputRequests = new Dictionary + { + ["input_1"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "low-level elicit", + RequestedSchema = new() + }) + } + }); + }, + new McpServerToolCreateOptions + { + Name = "incomplete-result-tool", + Description = "A tool that throws IncompleteResultException for low-level MRTR" + }), + McpServerTool.Create( + async (McpServer server, RequestContext context, CancellationToken ct) => + { + var requestState = context.Params!.RequestState; + var inputResponses = context.Params!.InputResponses; + + // Final round: we have the requestState from the IncompleteResultException + if (requestState == "got-name" && inputResponses is not null + && inputResponses.TryGetValue("age", out var ageResponse)) + { + var age = ageResponse.ElicitationResult?.Content?.FirstOrDefault().Value; + // Decode the name from requestState — in a real scenario, requestState + // would carry the accumulated state, but here we just verify the flow works. + return $"age={age}"; + } + + // First round: use high-level ElicitAsync (handler suspends) + var nameResult = await server.ElicitAsync(new ElicitRequestParams + { + Message = "What is your name?", + RequestedSchema = new() + }, ct); + + var name = nameResult.Content?.FirstOrDefault().Value; + + // Second round: switch to low-level IncompleteResultException (handler dies) + throw new IncompleteResultException( + inputRequests: new Dictionary + { + ["age"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = $"How old are you, {name}?", + RequestedSchema = new() + }) + }, + requestState: "got-name"); + }, + new McpServerToolCreateOptions + { + Name = "elicit-then-incomplete-result-tool", + Description = "A tool that uses high-level ElicitAsync then throws IncompleteResultException" + }), + McpServerTool.Create( + async (McpServer server) => + { + // Attempt to send a JsonRpcRequest via SendMessageAsync — should always throw + // since requests must go through SendRequestAsync for response correlation. + try + { + await server.SendMessageAsync(new JsonRpcRequest + { + Id = new RequestId(999), + Method = RequestMethods.ElicitationCreate, + Params = JsonSerializer.SerializeToNode(new ElicitRequestParams + { + Message = "Bypass attempt", + RequestedSchema = new() + }, McpJsonUtilities.DefaultOptions) + }); + return "NOT BLOCKED - expected InvalidOperationException"; + } + catch (InvalidOperationException ex) + { + return $"blocked:{ex.Message}"; + } + }, + new McpServerToolCreateOptions + { + Name = "sendmessage-bypass-tool", + Description = "A tool that attempts to bypass MRTR via SendMessageAsync" + }) + ]); + } + + [Fact] + public async Task CallToolAsync_BothExperimental_ElicitCompletesViaMrtr() + { + // Simplest MRTR success: experimental server + experimental client, one elicitation round. + StartServer(); + var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + clientOptions.Handlers.ElicitationHandler = (request, ct) => + new ValueTask(new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + ["name"] = JsonSerializer.SerializeToElement("Alice", McpJsonUtilities.DefaultOptions) + } + }); + + await using var client = await CreateMcpClientForServer(clientOptions); + Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion); + + var result = await client.CallToolAsync("elicitation-tool", + new Dictionary { ["message"] = "What is your name?" }, + cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Equal("accept:Alice", text); + Assert.True(result.IsError is not true); + _messageTracker.AssertMrtrUsed(); + } + + [Fact] + public async Task CallToolAsync_ConcurrentElicitAndSample_PropagatesError() + { + // MrtrContext only allows one pending request at a time. When a tool handler + // calls ElicitAsync and SampleAsync concurrently via Task.WhenAll, the second + // call sees the TCS already completed and throws InvalidOperationException. + // That exception is caught by the tool error handler and returned as IsError. + StartServer(); + var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + + // The first concurrent call (ElicitAsync) produces an IncompleteResult. + // The client resolves it via this handler, which unblocks the first task. + // Then Task.WhenAll surfaces the InvalidOperationException from the second task. + clientOptions.Handlers.ElicitationHandler = (request, ct) => + { + return new ValueTask(new ElicitResult { Action = "accept" }); + }; + clientOptions.Handlers.SamplingHandler = (request, progress, ct) => + { + return new ValueTask(new CreateMessageResult + { + Content = [new TextContentBlock { Text = "sampled" }], + Model = "test-model" + }); + }; + + await using var client = await CreateMcpClientForServer(clientOptions); + + var result = await client.CallToolAsync("concurrent-tool", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.IsError); + var errorText = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Contains("concurrent-tool", errorText); + _messageTracker.AssertMrtrUsed(); + } + + [Fact] + public async Task CallToolAsync_ElicitThenIncompleteResultException_WorksEndToEnd() + { + // Verify that a handler can mix high-level MRTR (ElicitAsync) with low-level MRTR + // (IncompleteResultException) in a single logical flow. The handler: + // 1. Calls ElicitAsync (high-level: handler suspends, IncompleteResult returned) + // 2. Gets the response, then throws IncompleteResultException (low-level: handler dies) + // 3. On the next retry, a fresh handler invocation processes requestState + inputResponses + StartServer(); + int elicitationCallCount = 0; + + var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + clientOptions.Handlers.ElicitationHandler = (request, ct) => + { + elicitationCallCount++; + if (request?.Message == "What is your name?") + { + return new ValueTask(new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + ["name"] = JsonDocument.Parse("\"Alice\"").RootElement.Clone() + } + }); + } + + // Second elicitation from the IncompleteResultException path + return new ValueTask(new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + ["age"] = JsonDocument.Parse("\"30\"").RootElement.Clone() + } + }); + }; + + await using var client = await CreateMcpClientForServer(clientOptions); + + var result = await client.CallToolAsync( + "elicit-then-incomplete-result-tool", + cancellationToken: TestContext.Current.CancellationToken); + + // Verify the final result came through correctly + var content = Assert.Single(result.Content); + Assert.Equal("age=30", Assert.IsType(content).Text); + Assert.NotEqual(true, result.IsError); + + // Two elicitations: one from ElicitAsync, one from IncompleteResultException's inputRequests + Assert.Equal(2, elicitationCallCount); + + // Verify no error-level logs for IncompleteResultException + Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => + m.LogLevel == LogLevel.Error && + m.Exception is IncompleteResultException); + _messageTracker.AssertMrtrUsed(); + } + + [Fact] + public async Task ClientHandlerException_DuringMrtrInputResolution_SurfacesToCaller() + { + // When the CLIENT's elicitation handler throws during MRTR input resolution, + // the retry never reaches the server — the server's handler remains suspended + // on ElicitAsync(). The exception should surface to the CallToolAsync caller, + // and the server's orphaned handler should be cleaned up on disposal. + // This is a fundamental MRTR limitation: the client has no channel to communicate + // input resolution failures back to the server. + StartServer(); + + var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + clientOptions.Handlers.ElicitationHandler = (request, ct) => + { + throw new InvalidOperationException("Client-side elicitation failure"); + }; + + await using var client = await CreateMcpClientForServer(clientOptions); + Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion); + + // The client handler throws during input resolution, so the exception + // escapes ResolveInputRequestAsync and surfaces directly to the caller. + var ex = await Assert.ThrowsAsync(async () => + await client.CallToolAsync("elicitation-tool", + new Dictionary { ["message"] = "Will fail" }, + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Equal("Client-side elicitation failure", ex.Message); + + // Dispose the server to trigger cleanup of the orphaned MRTR continuation. + // The server should cancel the handler suspended on ElicitAsync() and log + // the cancelled continuation at Debug level. + await Server.DisposeAsync(); + + Assert.Contains(MockLoggerProvider.LogMessages, m => + m.LogLevel == LogLevel.Debug && + m.Message.Contains("Cancelled") && + m.Message.Contains("MRTR continuation")); + } + + [Fact] + public async Task SendMessageAsync_WithJsonRpcRequest_ThrowsAlways() + { + // SendMessageAsync should throw InvalidOperationException if the message is a + // JsonRpcRequest, regardless of MRTR state. Use SendRequestAsync for requests. + StartServer(); + var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + clientOptions.Handlers.ElicitationHandler = (request, ct) => + new ValueTask(new ElicitResult { Action = "accept" }); + + await using var client = await CreateMcpClientForServer(clientOptions); + + var result = await client.CallToolAsync("sendmessage-bypass-tool", + cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.StartsWith("blocked:", text); + Assert.Contains("SendMessageAsync", text); + Assert.Contains("SendRequestAsync", text); + } + + [Fact] + public async Task LegacyRequestOnMrtrSession_LogsWarning() + { + // This test simulates a non-compliant server that negotiates MRTR + // but sends legacy elicitation/create JSON-RPC requests instead of + // using IncompleteResult. The client should handle it but log a warning. + StartServer(); // Required for base class DisposeAsync cleanup + var clientToServer = new Pipe(); + var serverToClient = new Pipe(); + + var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + clientOptions.Handlers.ElicitationHandler = (request, ct) => + new ValueTask(new ElicitResult { Action = "accept" }); + clientOptions.Handlers.SamplingHandler = (request, progress, ct) => + new ValueTask(new CreateMessageResult + { + Content = [new TextContentBlock { Text = "sampled" }], + Model = "test-model" + }); + + // Start the client task — it will send initialize and block waiting for response + var clientTask = McpClient.CreateAsync( + new StreamClientTransport( + clientToServer.Writer.AsStream(), + serverToClient.Reader.AsStream(), + LoggerFactory), + clientOptions, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + // Simulate server: read initialize request, respond with experimental version + var serverReader = new StreamReader(clientToServer.Reader.AsStream()); + var serverWriter = serverToClient.Writer.AsStream(); + + // Read the initialize request from client + var initLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); + Assert.NotNull(initLine); + var initRequest = JsonSerializer.Deserialize(initLine, McpJsonUtilities.DefaultOptions); + Assert.NotNull(initRequest); + Assert.Equal("initialize", initRequest.Method); + + // Respond with experimental protocol version (MRTR negotiated) + var initResponse = new JsonRpcResponse + { + Id = initRequest.Id, + Result = JsonSerializer.SerializeToNode(new InitializeResult + { + ProtocolVersion = "2026-06-XX", + Capabilities = new ServerCapabilities(), + ServerInfo = new Implementation { Name = "MockMrtrServer", Version = "1.0" } + }, McpJsonUtilities.DefaultOptions), + }; + await WriteJsonRpcAsync(serverWriter, initResponse); + + // Read the initialized notification from client + var initializedLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); + Assert.NotNull(initializedLine); + + // Client is now connected with MRTR negotiated + await using var client = await clientTask; + Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion); + + // Now simulate the non-compliant server sending a legacy elicitation/create request + var legacyRequest = new JsonRpcRequest + { + Id = new RequestId(42), + Method = RequestMethods.ElicitationCreate, + Params = JsonSerializer.SerializeToNode(new ElicitRequestParams + { + Message = "Legacy elicitation from non-compliant server", + RequestedSchema = new() + }, McpJsonUtilities.DefaultOptions), + }; + await WriteJsonRpcAsync(serverWriter, legacyRequest); + + // Read the client's response to the legacy request + var responseLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); + Assert.NotNull(responseLine); + var clientResponse = JsonSerializer.Deserialize(responseLine, McpJsonUtilities.DefaultOptions); + Assert.NotNull(clientResponse); + Assert.Equal(new RequestId(42), clientResponse.Id); + + // Verify the client handled the request (returned ElicitResult) + var elicitResult = JsonSerializer.Deserialize(clientResponse.Result, McpJsonUtilities.DefaultOptions); + Assert.NotNull(elicitResult); + Assert.Equal("accept", elicitResult.Action); + + // Verify the warning was logged + Assert.Contains(MockLoggerProvider.LogMessages, m => + m.LogLevel == LogLevel.Warning && + m.Message.Contains("elicitation/create") && + m.Message.Contains("MRTR")); + + // Clean up + clientToServer.Writer.Complete(); + serverToClient.Writer.Complete(); + } + + [Fact] + public async Task IncompleteResultOnNonMrtrSession_LogsWarning() + { + // This test simulates a non-compliant server that sends an IncompleteResult + // to a client that did NOT negotiate MRTR. The client should still process it + // (resilience), but log a warning about the unexpected protocol behavior. + StartServer(); // Required for base class DisposeAsync cleanup + var clientToServer = new Pipe(); + var serverToClient = new Pipe(); + + // Client does NOT set ExperimentalProtocolVersion — standard protocol only + var clientOptions = new McpClientOptions(); + clientOptions.Handlers.ElicitationHandler = (request, ct) => + new ValueTask(new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + ["confirmed"] = JsonDocument.Parse("\"yes\"").RootElement.Clone() + } + }); + + // Start the client task — it will send initialize and block waiting for response + var clientTask = McpClient.CreateAsync( + new StreamClientTransport( + clientToServer.Writer.AsStream(), + serverToClient.Reader.AsStream(), + LoggerFactory), + clientOptions, + loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + + var serverReader = new StreamReader(clientToServer.Reader.AsStream()); + var serverWriter = serverToClient.Writer.AsStream(); + + // Read the initialize request from client + var initLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); + Assert.NotNull(initLine); + var initRequest = JsonSerializer.Deserialize(initLine, McpJsonUtilities.DefaultOptions); + Assert.NotNull(initRequest); + Assert.Equal("initialize", initRequest.Method); + + // Respond with standard protocol version (no MRTR) + var initResponse = new JsonRpcResponse + { + Id = initRequest.Id, + Result = JsonSerializer.SerializeToNode(new InitializeResult + { + ProtocolVersion = "2025-03-26", + Capabilities = new ServerCapabilities { Tools = new() }, + ServerInfo = new Implementation { Name = "NonCompliantServer", Version = "1.0" } + }, McpJsonUtilities.DefaultOptions), + }; + await WriteJsonRpcAsync(serverWriter, initResponse); + + // Read the initialized notification from client + var initializedLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); + Assert.NotNull(initializedLine); + + // Client is now connected with standard protocol (no MRTR) + await using var client = await clientTask; + Assert.Equal("2025-03-26", client.NegotiatedProtocolVersion); + + // Start a background task to handle the client's tools/call request + var cancellationToken = TestContext.Current.CancellationToken; + var serverLoop = Task.Run(async () => + { + // Read tools/call request from client + var callLine = await serverReader.ReadLineAsync(cancellationToken); + Assert.NotNull(callLine); + var callRequest = JsonSerializer.Deserialize(callLine, McpJsonUtilities.DefaultOptions); + Assert.NotNull(callRequest); + Assert.Equal("tools/call", callRequest.Method); + + // Non-compliant server sends IncompleteResult on standard protocol session! + var incompleteResult = new JsonObject + { + ["result_type"] = "incomplete", + ["inputRequests"] = new JsonObject + { + ["confirm_1"] = JsonSerializer.SerializeToNode( + InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "Unexpected elicitation from non-compliant server", + RequestedSchema = new() + }), McpJsonUtilities.DefaultOptions) + }, + ["requestState"] = "non-mrtr-state" + }; + + var incompleteResponse = new JsonRpcResponse + { + Id = callRequest.Id, + Result = incompleteResult, + }; + await WriteJsonRpcAsync(serverWriter, incompleteResponse); + + // Read the retry request with inputResponses from client + var retryLine = await serverReader.ReadLineAsync(cancellationToken); + Assert.NotNull(retryLine); + var retryRequest = JsonSerializer.Deserialize(retryLine, McpJsonUtilities.DefaultOptions); + Assert.NotNull(retryRequest); + Assert.Equal("tools/call", retryRequest.Method); + + // Verify the retry contains inputResponses and requestState + var retryParams = retryRequest.Params as JsonObject; + Assert.NotNull(retryParams); + Assert.NotNull(retryParams["inputResponses"]); + Assert.Equal("non-mrtr-state", retryParams["requestState"]?.GetValue()); + + // Now respond with a normal result + var normalResult = new JsonRpcResponse + { + Id = retryRequest.Id, + Result = JsonSerializer.SerializeToNode(new CallToolResult + { + Content = [new TextContentBlock { Text = "completed-without-mrtr" }] + }, McpJsonUtilities.DefaultOptions), + }; + await WriteJsonRpcAsync(serverWriter, normalResult); + }, cancellationToken); + + // Client calls the tool — the non-compliant server will send IncompleteResult + var response = await client.SendRequestAsync( + new JsonRpcRequest + { + Method = "tools/call", + Params = JsonSerializer.SerializeToNode(new CallToolRequestParams + { + Name = "any-tool", + }, McpJsonUtilities.DefaultOptions) + }, + cancellationToken); + + await serverLoop; + + Assert.NotNull(response.Result); + var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); + Assert.NotNull(result); + var content = Assert.Single(result.Content); + Assert.Equal("completed-without-mrtr", Assert.IsType(content).Text); + + // Verify the warning was logged about IncompleteResult on non-MRTR session + Assert.Contains(MockLoggerProvider.LogMessages, m => + m.LogLevel == LogLevel.Warning && + m.Message.Contains("IncompleteResult") && + m.Message.Contains("did not negotiate MRTR")); + + // Clean up + clientToServer.Writer.Complete(); + serverToClient.Writer.Complete(); + } + + private static async Task WriteJsonRpcAsync(Stream writer, JsonRpcMessage message) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(message, McpJsonUtilities.DefaultOptions); + await writer.WriteAsync(bytes, TestContext.Current.CancellationToken); + await writer.WriteAsync("\n"u8.ToArray(), TestContext.Current.CancellationToken); + await writer.FlushAsync(TestContext.Current.CancellationToken); + } +} + +#endif diff --git a/tests/ModelContextProtocol.Tests/Protocol/MrtrSerializationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/MrtrSerializationTests.cs new file mode 100644 index 000000000..bb5b6d2d3 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/MrtrSerializationTests.cs @@ -0,0 +1,295 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Protocol; + +public static class MrtrSerializationTests +{ + [Fact] + public static void IncompleteResult_SerializationRoundTrip_PreservesAllProperties() + { + var original = new IncompleteResult + { + InputRequests = new Dictionary + { + ["input_1"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "What is your name?", + RequestedSchema = new() + }), + ["input_2"] = InputRequest.ForSampling(new CreateMessageRequestParams + { + Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Hello" }] }], + MaxTokens = 100 + }) + }, + RequestState = "correlation-123", + }; + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal("incomplete", deserialized.ResultType); + Assert.Equal("correlation-123", deserialized.RequestState); + Assert.NotNull(deserialized.InputRequests); + Assert.Equal(2, deserialized.InputRequests.Count); + Assert.True(deserialized.InputRequests.ContainsKey("input_1")); + Assert.True(deserialized.InputRequests.ContainsKey("input_2")); + } + + [Fact] + public static void IncompleteResult_HasResultTypeIncomplete() + { + var result = new IncompleteResult(); + Assert.Equal("incomplete", result.ResultType); + } + + [Fact] + public static void IncompleteResult_ResultType_AppearsInJson() + { + var result = new IncompleteResult + { + RequestState = "abc", + }; + + string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json); + + Assert.NotNull(node); + Assert.Equal("incomplete", (string?)node["result_type"]); + Assert.Equal("abc", (string?)node["requestState"]); + } + + [Fact] + public static void InputRequest_ForElicitation_SerializesCorrectly() + { + var inputRequest = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "Enter name", + RequestedSchema = new() + }); + + string json = JsonSerializer.Serialize(inputRequest, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json); + + Assert.NotNull(node); + Assert.Equal("elicitation/create", (string?)node["method"]); + Assert.NotNull(node["params"]); + Assert.Equal("Enter name", (string?)node["params"]!["message"]); + } + + [Fact] + public static void InputRequest_ForSampling_SerializesCorrectly() + { + var inputRequest = InputRequest.ForSampling(new CreateMessageRequestParams + { + Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Prompt" }] }], + MaxTokens = 50 + }); + + string json = JsonSerializer.Serialize(inputRequest, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json); + + Assert.NotNull(node); + Assert.Equal("sampling/createMessage", (string?)node["method"]); + Assert.NotNull(node["params"]); + Assert.Equal(50, (int?)node["params"]!["maxTokens"]); + } + + [Fact] + public static void InputRequest_ForRootsList_SerializesCorrectly() + { + var inputRequest = InputRequest.ForRootsList(new ListRootsRequestParams()); + + string json = JsonSerializer.Serialize(inputRequest, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json); + + Assert.NotNull(node); + Assert.Equal("roots/list", (string?)node["method"]); + } + + [Fact] + public static void InputRequest_Elicitation_RoundTrip() + { + var original = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "test message", + RequestedSchema = new() + }); + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal("elicitation/create", deserialized.Method); + Assert.NotNull(deserialized.ElicitationParams); + Assert.Equal("test message", deserialized.ElicitationParams.Message); + } + + [Fact] + public static void InputRequest_Sampling_RoundTrip() + { + var original = InputRequest.ForSampling(new CreateMessageRequestParams + { + Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Hello" }] }], + MaxTokens = 200 + }); + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal("sampling/createMessage", deserialized.Method); + Assert.NotNull(deserialized.SamplingParams); + Assert.Equal(200, deserialized.SamplingParams.MaxTokens); + } + + [Fact] + public static void InputRequest_RootsList_RoundTrip() + { + var original = InputRequest.ForRootsList(new ListRootsRequestParams()); + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal("roots/list", deserialized.Method); + Assert.NotNull(deserialized.RootsParams); + } + + [Fact] + public static void InputResponse_FromSamplingResult_RoundTrip() + { + var samplingResult = new CreateMessageResult + { + Content = [new TextContentBlock { Text = "Response text" }], + Model = "test-model" + }; + + var inputResponse = InputResponse.FromSamplingResult(samplingResult); + + // Serialize → deserialize + string json = JsonSerializer.Serialize(inputResponse, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.NotNull(deserialized.SamplingResult); + Assert.Equal("test-model", deserialized.SamplingResult.Model); + } + + [Fact] + public static void InputResponse_FromElicitResult_RoundTrip() + { + var elicitResult = new ElicitResult + { + Action = "confirm", + Content = new Dictionary + { + ["key"] = JsonDocument.Parse("\"value\"").RootElement.Clone() + } + }; + + var inputResponse = InputResponse.FromElicitResult(elicitResult); + + string json = JsonSerializer.Serialize(inputResponse, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.NotNull(deserialized.ElicitationResult); + Assert.Equal("confirm", deserialized.ElicitationResult.Action); + } + + [Fact] + public static void InputResponse_FromRootsResult_RoundTrip() + { + var rootsResult = new ListRootsResult + { + Roots = [new Root { Uri = "file:///test", Name = "Test" }] + }; + + var inputResponse = InputResponse.FromRootsResult(rootsResult); + + string json = JsonSerializer.Serialize(inputResponse, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.NotNull(deserialized.RootsResult); + Assert.Single(deserialized.RootsResult.Roots); + Assert.Equal("file:///test", deserialized.RootsResult.Roots[0].Uri); + } + + [Fact] + public static void InputRequestDictionary_SerializationRoundTrip() + { + IDictionary requests = new Dictionary + { + ["a"] = InputRequest.ForElicitation(new ElicitRequestParams { Message = "q1", RequestedSchema = new() }), + ["b"] = InputRequest.ForSampling(new CreateMessageRequestParams + { + Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "q2" }] }], + MaxTokens = 50 + }), + }; + + string json = JsonSerializer.Serialize(requests, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize>(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal(2, deserialized.Count); + Assert.Equal("elicitation/create", deserialized["a"].Method); + Assert.Equal("sampling/createMessage", deserialized["b"].Method); + } + + [Fact] + public static void InputResponseDictionary_SerializationRoundTrip() + { + IDictionary responses = new Dictionary + { + ["a"] = InputResponse.FromElicitResult(new ElicitResult { Action = "confirm" }), + ["b"] = InputResponse.FromSamplingResult(new CreateMessageResult + { + Content = [new TextContentBlock { Text = "AI" }], + Model = "m1" + }), + }; + + string json = JsonSerializer.Serialize(responses, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize>(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal(2, deserialized.Count); + } + + [Fact] + public static void Result_ResultType_DefaultsToNull() + { + var result = new CallToolResult + { + Content = [new TextContentBlock { Text = "test" }] + }; + + string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json); + + // result_type should not appear for normal results + Assert.Null(node?["result_type"]); + } + + [Fact] + public static void RequestParams_InputResponses_NotSerializedByDefault() + { + var callParams = new CallToolRequestParams + { + Name = "test-tool", + }; + + string json = JsonSerializer.Serialize(callParams, McpJsonUtilities.DefaultOptions); + var node = JsonNode.Parse(json); + + // inputResponses and requestState should not appear when null + Assert.Null(node?["inputResponses"]); + Assert.Null(node?["requestState"]); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs new file mode 100644 index 000000000..60060127d --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs @@ -0,0 +1,438 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.Text.Json; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Tests for the server's MRTR handler lifecycle management — cancellation, disposal, and error +/// logging during multi round-trip request processing. +/// +public class MrtrHandlerLifecycleTests : ClientServerTestBase +{ + private readonly TaskCompletionSource _handlerTokenCancelled = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _handlerStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _handlerResumed = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _releaseHandler = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly ServerMessageTracker _messageTracker = new(); + + public MrtrHandlerLifecycleTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug)); + services.Configure(options => + { + options.ExperimentalProtocolVersion = "2026-06-XX"; + _messageTracker.AddFilters(options.Filters.Message); + }); + + mcpServerBuilder.WithTools([ + McpServerTool.Create( + async (string message, McpServer server, CancellationToken ct) => + { + var result = await server.ElicitAsync(new ElicitRequestParams + { + Message = message, + RequestedSchema = new() + }, ct); + + return $"{result.Action}:{result.Content?.FirstOrDefault().Value}"; + }, + new McpServerToolCreateOptions + { + Name = "elicitation-tool", + Description = "A tool that requests elicitation from the client" + }), + McpServerTool.Create( + async (McpServer server, CancellationToken ct) => + { + var handlerTokenCancelled = _handlerTokenCancelled; + ct.Register(static state => ((TaskCompletionSource)state!).TrySetResult(true), handlerTokenCancelled); + _handlerStarted.TrySetResult(true); + + await server.ElicitAsync(new ElicitRequestParams + { + Message = "Cancellation test", + RequestedSchema = new() + }, ct); + + return "done"; + }, + new McpServerToolCreateOptions + { + Name = "cancellation-test-tool", + Description = "A tool that monitors its CancellationToken during MRTR" + }), + McpServerTool.Create( + async (string message, McpServer server, CancellationToken ct) => + { + // Elicit first, then block forever — the retry request stays in-flight + // until the client cancels, verifying that notifications/cancelled for + // the retry's request ID flows through to cancel this handler. + _handlerStarted.TrySetResult(true); + var result = await server.ElicitAsync(new ElicitRequestParams + { + Message = message, + RequestedSchema = new() + }, ct); + + // Signal that we resumed after ElicitAsync, then block. + _handlerResumed.TrySetResult(true); + await Task.Delay(Timeout.Infinite, ct); + return "unreachable"; + }, + new McpServerToolCreateOptions + { + Name = "elicit-then-block-tool", + Description = "A tool that elicits then blocks forever for cancellation testing" + }), + McpServerTool.Create( + async (McpServer server, CancellationToken ct) => + { + // Two sequential MRTR rounds. The client will inject a stale cancellation + // notification for the original request ID between round 1 and round 2. + var r1 = await server.ElicitAsync(new ElicitRequestParams + { + Message = "First elicitation", + RequestedSchema = new() + }, ct); + + // Signal that round 1 completed so the test can inject the stale notification. + _handlerResumed.TrySetResult(true); + + var r2 = await server.ElicitAsync(new ElicitRequestParams + { + Message = "Second elicitation", + RequestedSchema = new() + }, ct); + + return $"{r1.Action},{r2.Action}"; + }, + new McpServerToolCreateOptions + { + Name = "double-elicit-tool", + Description = "A tool that elicits twice for stale cancellation testing" + }), + McpServerTool.Create( + async (string message, McpServer server, CancellationToken ct) => + { + // Elicit, resume, then wait on _releaseHandler for the dispose test. + _handlerStarted.TrySetResult(true); + await server.ElicitAsync(new ElicitRequestParams + { + Message = message, + RequestedSchema = new() + }, ct); + + _handlerResumed.TrySetResult(true); + await _releaseHandler.Task; + return "handler-completed"; + }, + new McpServerToolCreateOptions + { + Name = "dispose-wait-tool", + Description = "A tool that elicits, resumes, then waits on a signal for disposal testing" + }), + McpServerTool.Create( + async (McpServer server, CancellationToken ct) => + { + await server.ElicitAsync(new ElicitRequestParams + { + Message = "elicit-then-throw", + RequestedSchema = new() + }, ct); + + throw new InvalidOperationException("Deliberate MRTR handler error for testing"); + }, + new McpServerToolCreateOptions + { + Name = "elicit-then-throw-tool", + Description = "A tool that elicits then throws an exception for error logging testing" + }), + McpServerTool.Create( + (McpServer server) => + { + // Low-level MRTR: throw IncompleteResultException directly instead of using ElicitAsync. + // This should NOT be logged at Error level — it's normal MRTR control flow. + throw new IncompleteResultException(new IncompleteResult + { + InputRequests = new Dictionary + { + ["input_1"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "low-level elicit", + RequestedSchema = new() + }) + } + }); + }, + new McpServerToolCreateOptions + { + Name = "incomplete-result-tool", + Description = "A tool that throws IncompleteResultException for low-level MRTR" + }) + ]); + } + + [Fact] + public async Task CallToolAsync_CancellationDuringMrtrRetry_ThrowsOperationCanceled() + { + // Verify that cancelling the CancellationToken during the MRTR retry loop + // (specifically during the elicitation handler callback) stops the loop. + StartServer(); + var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + + var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + clientOptions.Handlers.ElicitationHandler = (request, ct) => + { + // Cancel the token during the callback. The retry loop will throw + // OperationCanceledException on the next await after this handler returns. + cts.Cancel(); + return new ValueTask(new ElicitResult { Action = "accept" }); + }; + + await using var client = await CreateMcpClientForServer(clientOptions); + + await Assert.ThrowsAsync(async () => + await client.CallToolAsync("elicitation-tool", + new Dictionary { ["message"] = "test" }, + cancellationToken: cts.Token)); + + _messageTracker.AssertMrtrUsed(); + } + + [Fact] + public async Task ServerDisposal_CancelsHandlerCancellationToken_DuringMrtr() + { + // Verify that disposing the server cancels the handler's own CancellationToken + // (the `ct` parameter), not just the exchange ResponseTcs. Before the HandlerCts fix, + // the handler's CT was from a disposed CTS and could never be triggered. + StartServer(); + var elicitHandlerCalled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + clientOptions.Handlers.ElicitationHandler = async (request, ct) => + { + // Signal that the MRTR round trip reached the client, then block indefinitely. + elicitHandlerCalled.TrySetResult(true); + await Task.Delay(Timeout.Infinite, ct); + throw new OperationCanceledException(ct); + }; + + await using var client = await CreateMcpClientForServer(clientOptions); + + // Start the tool call in the background. + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(30)); + var callTask = client.CallToolAsync("cancellation-test-tool", cancellationToken: cts.Token).AsTask(); + + // Wait for the handler to start on the server. + await _handlerStarted.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); + + // Wait for the MRTR round trip to reach the client's elicitation handler. + await elicitHandlerCalled.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); + + // Dispose the server — HandlerCts.Cancel() should trigger the handler's CancellationToken. + await Server.DisposeAsync(); + + // Verify the handler's CancellationToken was actually cancelled via HandlerCts, + // not just the exchange ResponseTcs.TrySetCanceled(). + await _handlerTokenCancelled.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); + + // The client call should fail (server disposed mid-MRTR). + await Assert.ThrowsAnyAsync(async () => await callTask); + } + + [Fact] + public async Task CancellationNotification_DuringInFlightMrtrRetry_CancelsHandler() + { + // Verify that cancelling the client's CancellationToken while a retry request is in-flight + // sends notifications/cancelled with the retry's request ID, and the server correctly + // routes it to cancel the handler. This proves end-to-end that: + // (a) the client sends the notification with the CURRENT request ID (not the original), + // (b) the server's _handlingRequests lookup finds the retry's CTS, + // (c) the cancellation registration in AwaitMrtrHandlerAsync bridges to handlerCts. + StartServer(); + + var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + clientOptions.Handlers.ElicitationHandler = (request, ct) => + new ValueTask(new ElicitResult { Action = "accept" }); + + await using var client = await CreateMcpClientForServer(clientOptions); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(30)); + var callTask = client.CallToolAsync( + "elicit-then-block-tool", + new Dictionary { ["message"] = "test" }, + cancellationToken: cts.Token).AsTask(); + + // Wait for the handler to resume after ElicitAsync — at this point the retry + // request is in-flight (server is awaiting WhenAny in AwaitMrtrHandlerAsync). + await _handlerResumed.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); + + // Cancel the client's token. The client is inside _sessionHandler.SendRequestAsync + // awaiting the retry response. RegisterCancellation fires and sends + // notifications/cancelled with the retry's request ID. + cts.Cancel(); + + // The call should throw OperationCanceledException. + await Assert.ThrowsAnyAsync(async () => await callTask); + + _messageTracker.AssertMrtrUsed(); + } + + [Fact] + public async Task CancellationNotification_ForExpiredRequestId_DoesNotAffectHandler() + { + // Verify that a stale cancellation notification for the original (now-completed) + // request ID does not interfere with an active MRTR handler. The original request's + // entry was removed from _handlingRequests when it returned IncompleteResult, so + // the notification should be a no-op. + StartServer(); + + int elicitationCount = 0; + var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + clientOptions.Handlers.ElicitationHandler = (request, ct) => + { + Interlocked.Increment(ref elicitationCount); + return new ValueTask(new ElicitResult { Action = "accept" }); + }; + + await using var client = await CreateMcpClientForServer(clientOptions); + + // Start the double-elicit tool. Between round 1 and round 2, we'll inject a stale + // cancellation notification for a fake (expired) request ID. + var callTask = client.CallToolAsync( + "double-elicit-tool", + cancellationToken: TestContext.Current.CancellationToken).AsTask(); + + // Wait for handler to resume after the first ElicitAsync. + await _handlerResumed.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); + + // Send a stale cancellation notification for a non-existent request ID. + // This simulates a delayed notification for the original request that already completed. + await client.SendMessageAsync(new JsonRpcNotification + { + Method = NotificationMethods.CancelledNotification, + Params = JsonSerializer.SerializeToNode( + new CancelledNotificationParams { RequestId = new RequestId("stale-id-999"), Reason = "stale test" }, + McpJsonUtilities.DefaultOptions), + }, TestContext.Current.CancellationToken); + + // The tool should complete successfully — the stale notification didn't affect it. + var result = await callTask; + Assert.Contains("accept", result.Content.OfType().First().Text); + + _messageTracker.AssertMrtrUsed(); + } + + [Fact] + public async Task DisposeAsync_WaitsForMrtrHandler_BeforeReturning() + { + // Verify that McpServer.DisposeAsync() waits for an MRTR handler to complete + // before returning, similar to RunAsync_WaitsForInFlightHandlersBeforeReturning + // which tests the same invariant for regular request handlers in McpSessionHandler. + StartServer(); + bool handlerCompleted = false; + + var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + clientOptions.Handlers.ElicitationHandler = (request, ct) => + new ValueTask(new ElicitResult { Action = "accept" }); + + await using var client = await CreateMcpClientForServer(clientOptions); + + // Start the tool call that calls ElicitAsync, then blocks on _releaseHandler. + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(30)); + _ = client.CallToolAsync( + "dispose-wait-tool", + new Dictionary { ["message"] = "dispose-wait-test" }, + cancellationToken: cts.Token); + + // Wait for the handler to resume after ElicitAsync — it's now blocking on _releaseHandler. + await _handlerResumed.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); + + // Dispose the server. The handler is still running (blocked on _releaseHandler). + // Release the handler after a delay — DisposeAsync must wait for it. + var ct = TestContext.Current.CancellationToken; + _ = Task.Run(async () => + { + await Task.Delay(200, ct); + handlerCompleted = true; + _releaseHandler.SetResult(true); + }, ct); + + await Server.DisposeAsync(); + + // DisposeAsync should not have returned until the handler completed. + Assert.True(handlerCompleted, "DisposeAsync should wait for MRTR handlers to complete before returning."); + + _messageTracker.AssertMrtrUsed(); + } + + [Fact] + public async Task HandlerException_DuringMrtr_IsLoggedAtErrorLevel() + { + // Verify that when a tool handler throws an unhandled exception during MRTR + // (after resuming from ElicitAsync), the error is logged at Error level. + StartServer(); + + var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + clientOptions.Handlers.ElicitationHandler = (request, ct) => + new ValueTask(new ElicitResult { Action = "accept" }); + + await using var client = await CreateMcpClientForServer(clientOptions); + + // Call the tool that elicits then throws. The retry returns an error result. + var result = await client.CallToolAsync( + "elicit-then-throw-tool", + cancellationToken: TestContext.Current.CancellationToken); + Assert.True(result.IsError); + + // Verify the tool error was logged at Error level during the MRTR retry. + // The ToolsCall handler catches the exception, logs it via ToolCallError, + // and converts it to an error result — so the error is properly surfaced. + Assert.Contains(MockLoggerProvider.LogMessages, m => + m.LogLevel == LogLevel.Error && + m.Message.Contains("elicit-then-throw-tool") && + m.Exception is InvalidOperationException); + + _messageTracker.AssertMrtrUsed(); + } + + [Fact] + public async Task IncompleteResultException_IsNotLoggedAtErrorLevel() + { + // IncompleteResultException is normal MRTR control flow (low-level API), + // not an error. It should not be logged via ToolCallError at Error level. + StartServer(); + + var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + clientOptions.Handlers.ElicitationHandler = (request, ct) => + new ValueTask(new ElicitResult { Action = "accept" }); + + await using var client = await CreateMcpClientForServer(clientOptions); + + // The tool always throws IncompleteResultException (low-level MRTR path), + // so the client will retry until hitting the max retry limit. + await Assert.ThrowsAsync(() => client.CallToolAsync( + "incomplete-result-tool", + cancellationToken: TestContext.Current.CancellationToken).AsTask()); + + Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => + m.LogLevel == LogLevel.Error && + m.Exception is IncompleteResultException); + + _messageTracker.AssertMrtrUsed(); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrLowLevelApiTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrLowLevelApiTests.cs new file mode 100644 index 000000000..bcf76bb95 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/MrtrLowLevelApiTests.cs @@ -0,0 +1,62 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Tests for the low-level MRTR server API — IsMrtrSupported, IncompleteResultException, +/// and client auto-retry of incomplete results. +/// +public class MrtrLowLevelApiTests : ClientServerTestBase +{ + private readonly ServerMessageTracker _messageTracker = new(); + + public MrtrLowLevelApiTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + services.Configure(options => + { + options.ExperimentalProtocolVersion = "2026-06-XX"; + _messageTracker.AddFilters(options.Filters.Message); + }); + + mcpServerBuilder.WithTools([ + McpServerTool.Create( + static string (McpServer server) => + { + throw new IncompleteResultException(requestState: "should-not-work"); + }, + new McpServerToolCreateOptions + { + Name = "always-incomplete", + Description = "Tool that always throws IncompleteResultException" + }), + ]); + } + + [Fact] + public async Task LowLevel_IncompleteResultException_WithoutExperimental_ReturnsError() + { + StartServer(); + // Client does NOT set ExperimentalProtocolVersion + var clientOptions = new McpClientOptions(); + + await using var client = await CreateMcpClientForServer(clientOptions); + + // The always-incomplete tool throws IncompleteResultException with only requestState + // and no inputRequests. Without MRTR negotiated, the backcompat layer can't resolve + // the request (no inputRequests to dispatch), so it wraps it in an error. + var exception = await Assert.ThrowsAsync(() => + client.CallToolAsync("always-incomplete", + cancellationToken: TestContext.Current.CancellationToken).AsTask()); + + Assert.Contains("without input requests", exception.Message); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs new file mode 100644 index 000000000..54e52281a --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs @@ -0,0 +1,149 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.Text.Json; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Tests that message filters correctly observe MRTR protocol behavior — verifying that +/// IncompleteResult responses are visible to outgoing filters, and that no legacy +/// elicitation/sampling requests are sent when MRTR is active. +/// +public class MrtrMessageFilterTests : ClientServerTestBase +{ + private readonly ServerMessageTracker _messageTracker = new(); + + public MrtrMessageFilterTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + services.Configure(options => + { + options.ExperimentalProtocolVersion = "2026-06-XX"; + _messageTracker.AddFilters(options.Filters.Message); + }); + + mcpServerBuilder + .WithTools([ + McpServerTool.Create( + async (string message, McpServer server, CancellationToken ct) => + { + var result = await server.ElicitAsync(new ElicitRequestParams + { + Message = message, + RequestedSchema = new() + }, ct); + + return $"{result.Action}"; + }, + new McpServerToolCreateOptions + { + Name = "elicit-tool", + Description = "A tool that requests elicitation" + }), + McpServerTool.Create( + async (string prompt, McpServer server, CancellationToken ct) => + { + var result = await server.SampleAsync(new CreateMessageRequestParams + { + Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = prompt }] }], + MaxTokens = 100 + }, ct); + + return result.Content.OfType().FirstOrDefault()?.Text ?? ""; + }, + new McpServerToolCreateOptions + { + Name = "sample-tool", + Description = "A tool that requests sampling" + }), + ]); + } + + [Fact] + public async Task MrtrActive_NoOldStyleElicitationRequests_SentOverWire() + { + // When both sides are on the experimental protocol, the server should use MRTR + // (IncompleteResult) instead of sending old-style elicitation/create JSON-RPC requests. + StartServer(); + var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + clientOptions.Handlers.ElicitationHandler = (request, ct) => + { + return new ValueTask(new ElicitResult { Action = "accept" }); + }; + + await using var client = await CreateMcpClientForServer(clientOptions); + Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion); + + var result = await client.CallToolAsync("elicit-tool", + new Dictionary { ["message"] = "test" }, + cancellationToken: TestContext.Current.CancellationToken); + + var content = Assert.Single(result.Content); + Assert.Equal("accept", Assert.IsType(content).Text); + _messageTracker.AssertMrtrUsed(); + } + + [Fact] + public async Task MrtrActive_NoOldStyleSamplingRequests_SentOverWire() + { + StartServer(); + var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + clientOptions.Handlers.SamplingHandler = (request, progress, ct) => + { + var text = request?.Messages[^1].Content.OfType().FirstOrDefault()?.Text; + return new ValueTask(new CreateMessageResult + { + Content = [new TextContentBlock { Text = $"Sampled: {text}" }], + Model = "test-model" + }); + }; + + await using var client = await CreateMcpClientForServer(clientOptions); + Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion); + + var result = await client.CallToolAsync("sample-tool", + new Dictionary { ["prompt"] = "test" }, + cancellationToken: TestContext.Current.CancellationToken); + + var content = Assert.Single(result.Content); + Assert.Equal("Sampled: test", Assert.IsType(content).Text); + _messageTracker.AssertMrtrUsed(); + } + + [Fact] + public async Task OutgoingFilter_SeesIncompleteResultResponse() + { + // Verify that transport middleware can observe the raw IncompleteResult + // in outgoing JSON-RPC responses (validates MRTR transport visibility). + var sawIncompleteResult = false; + + StartServer(); + var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + clientOptions.Handlers.ElicitationHandler = (request, ct) => + { + // If we reach this handler, it means the client received an IncompleteResult + // from the server, resolved the elicitation, and is retrying. + sawIncompleteResult = true; + return new ValueTask(new ElicitResult { Action = "accept" }); + }; + + await using var client = await CreateMcpClientForServer(clientOptions); + + await client.CallToolAsync("elicit-tool", + new Dictionary { ["message"] = "test" }, + cancellationToken: TestContext.Current.CancellationToken); + + // The elicitation handler was called, confirming MRTR round-trip occurred + // (IncompleteResult was sent by server and processed by client). + Assert.True(sawIncompleteResult, "Expected MRTR round-trip with IncompleteResult"); + _messageTracker.AssertMrtrUsed(); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs new file mode 100644 index 000000000..24d7d96fc --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs @@ -0,0 +1,183 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.Collections.Concurrent; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Tests for session-scoped MRTR resource governance — verifying that outgoing message +/// filters can track and limit MRTR round trips per session. +/// +public class MrtrSessionLimitTests : ClientServerTestBase +{ + /// + /// Tracks the number of pending MRTR flows per session. Incremented when an IncompleteResult + /// is sent (outgoing filter), decremented when a retry with requestState arrives (incoming filter). + /// + private readonly ConcurrentDictionary _pendingFlowsPerSession = new(); + + /// + /// Records every (sessionId, pendingCount) observation from the outgoing filter, + /// so the test can verify the tracking was correct. + /// + private readonly ConcurrentBag<(string SessionId, int PendingCount)> _observations = []; + + private readonly ServerMessageTracker _messageTracker = new(); + + /// + /// Maximum allowed concurrent MRTR flows per session. If exceeded, the outgoing filter + /// replaces the IncompleteResult with an error response. + /// + private int _maxFlowsPerSession = int.MaxValue; + + /// + /// Counts how many IncompleteResults were blocked by the per-session limit. + /// + private int _blockedFlowCount; + + public MrtrSessionLimitTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + services.Configure(options => + { + options.ExperimentalProtocolVersion = "2026-06-XX"; + _messageTracker.AddFilters(options.Filters.Message); + + // Outgoing filter: detect IncompleteResult responses and track per session. + options.Filters.Message.OutgoingFilters.Add(next => async (context, cancellationToken) => + { + if (context.JsonRpcMessage is JsonRpcResponse response && + response.Result is JsonObject resultObj && + resultObj.TryGetPropertyValue("result_type", out var resultTypeNode) && + resultTypeNode?.GetValue() is "incomplete") + { + var sessionId = context.Server.SessionId ?? "unknown"; + var newCount = _pendingFlowsPerSession.AddOrUpdate(sessionId, 1, (_, c) => c + 1); + _observations.Add((sessionId, newCount)); + + // Enforce per-session limit: if exceeded, replace the IncompleteResult + // with a JSON-RPC error. This prevents the client from receiving the + // IncompleteResult and starting another retry cycle. + if (newCount > _maxFlowsPerSession) + { + // Undo the increment since we're blocking this flow. + _pendingFlowsPerSession.AddOrUpdate(sessionId, 0, (_, c) => Math.Max(0, c - 1)); + Interlocked.Increment(ref _blockedFlowCount); + + // Replace the outgoing message with a JSON-RPC error. + context.JsonRpcMessage = new JsonRpcError + { + Id = response.Id, + Error = new JsonRpcErrorDetail + { + Code = (int)McpErrorCode.InvalidRequest, + Message = $"Too many pending MRTR flows for this session (limit: {_maxFlowsPerSession}).", + } + }; + } + } + + await next(context, cancellationToken); + }); + + // Incoming filter: detect retries (requests with requestState) and decrement. + options.Filters.Message.IncomingFilters.Add(next => async (context, cancellationToken) => + { + if (context.JsonRpcMessage is JsonRpcRequest request && + request.Params is JsonObject paramsObj && + paramsObj.TryGetPropertyValue("requestState", out var stateNode) && + stateNode is not null) + { + var sessionId = context.Server.SessionId ?? "unknown"; + _pendingFlowsPerSession.AddOrUpdate(sessionId, 0, (_, c) => Math.Max(0, c - 1)); + } + + await next(context, cancellationToken); + }); + }); + + mcpServerBuilder.WithTools([ + McpServerTool.Create( + async (string message, McpServer server, CancellationToken ct) => + { + var result = await server.ElicitAsync(new ElicitRequestParams + { + Message = message, + RequestedSchema = new() + }, ct); + + return $"{result.Action}"; + }, + new McpServerToolCreateOptions + { + Name = "elicit-tool", + Description = "A tool that requests elicitation" + }), + ]); + } + + [Fact] + public async Task OutgoingFilter_TracksIncompleteResultsPerSession() + { + // Verify that an outgoing message filter can observe IncompleteResult responses + // and track the pending MRTR flow count per session using context.Server.SessionId. + StartServer(); + var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + clientOptions.Handlers.ElicitationHandler = (request, ct) => + new ValueTask(new ElicitResult { Action = "accept" }); + + await using var client = await CreateMcpClientForServer(clientOptions); + + // Call the tool — triggers one MRTR round-trip. + var result = await client.CallToolAsync("elicit-tool", + new Dictionary { ["message"] = "confirm?" }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("accept", Assert.IsType(Assert.Single(result.Content)).Text); + + // Verify the filter observed exactly one IncompleteResult and tracked it. + Assert.Single(_observations); + var (sessionId, pendingCount) = _observations.First(); + Assert.NotNull(sessionId); + Assert.Equal(1, pendingCount); + + // After the retry completed, the count should be back to 0. + Assert.Equal(0, _pendingFlowsPerSession.GetValueOrDefault(sessionId)); + + _messageTracker.AssertMrtrUsed(); + } + + [Fact] + public async Task OutgoingFilter_CanEnforcePerSessionMrtrLimit() + { + // Verify that an outgoing message filter can enforce a per-session MRTR flow limit + // by replacing the IncompleteResult with a JSON-RPC error when the limit is exceeded. + // Set the limit to 0 so the very first MRTR flow is blocked. + _maxFlowsPerSession = 0; + + StartServer(); + var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + clientOptions.Handlers.ElicitationHandler = (request, ct) => + new ValueTask(new ElicitResult { Action = "accept" }); + + await using var client = await CreateMcpClientForServer(clientOptions); + + // The tool call should fail because the outgoing filter blocks the IncompleteResult. + var ex = await Assert.ThrowsAsync(async () => + await client.CallToolAsync("elicit-tool", + new Dictionary { ["message"] = "confirm?" }, + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("Too many pending MRTR flows", ex.Message); + Assert.Equal(1, _blockedFlowCount); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrTaskIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrTaskIntegrationTests.cs new file mode 100644 index 000000000..7c32b54a9 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/MrtrTaskIntegrationTests.cs @@ -0,0 +1,295 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Tests for the interaction between MRTR and the Tasks feature — verifying that MRTR-driven +/// tool calls correctly track task status (InputRequired), and that task-based sampling +/// bypasses MRTR interception. +/// +public class MrtrTaskIntegrationTests : ClientServerTestBase +{ + public MrtrTaskIntegrationTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + var taskStore = new InMemoryMcpTaskStore(); + services.AddSingleton(taskStore); + services.Configure(options => + { + options.TaskStore = taskStore; + options.ExperimentalProtocolVersion = "2026-06-XX"; + }); + + mcpServerBuilder.WithTools([ + McpServerTool.Create( + async (string prompt, McpServer server, CancellationToken ct) => + { + // This tool calls SampleAsync which goes through MRTR when the client supports it. + // When running in a task context, SendRequestWithTaskStatusTrackingAsync should + // set task status to InputRequired while awaiting the sampling result. + var result = await server.SampleAsync(new CreateMessageRequestParams + { + Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = prompt }] }], + MaxTokens = 100 + }, ct); + + return result.Content.OfType().FirstOrDefault()?.Text ?? "No response"; + }, + new McpServerToolCreateOptions + { + Name = "sampling-tool", + Description = "A tool that requests sampling from the client" + }), + McpServerTool.Create( + async (string message, McpServer server, CancellationToken ct) => + { + var result = await server.ElicitAsync(new ElicitRequestParams + { + Message = message, + RequestedSchema = new() + }, ct); + + return $"{result.Action}"; + }, + new McpServerToolCreateOptions + { + Name = "elicitation-tool", + Description = "A tool that requests elicitation from the client" + }), + ]); + } + + [Fact] + public async Task TaskAugmentedToolCall_WithMrtrSampling_TracksInputRequiredStatus() + { + StartServer(); + var taskStore = new InMemoryMcpTaskStore(); + var samplingStarted = new TaskCompletionSource(); + var samplingCanProceed = new TaskCompletionSource(); + + var clientOptions = new McpClientOptions + { + ExperimentalProtocolVersion = "2026-06-XX", + TaskStore = taskStore, + Handlers = new McpClientHandlers + { + SamplingHandler = async (request, progress, ct) => + { + samplingStarted.TrySetResult(true); + // Wait until test signals to proceed — this gives us time to check task status + await samplingCanProceed.Task.WaitAsync(ct); + return new CreateMessageResult + { + Content = [new TextContentBlock { Text = "Sampled response" }], + Model = "test-model" + }; + } + } + }; + + await using var client = await CreateMcpClientForServer(clientOptions); + + // Start task-augmented tool call + var mcpTask = await Server.SampleAsTaskAsync( + new CreateMessageRequestParams + { + Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Test" }] }], + MaxTokens = 100 + }, + new McpTaskMetadata(), + TestContext.Current.CancellationToken); + + Assert.NotNull(mcpTask); + Assert.Equal(McpTaskStatus.Working, mcpTask.Status); + + // Wait for sampling handler to be called — this means MRTR resolved the input request + await samplingStarted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + + // Let the sampling handler complete + samplingCanProceed.TrySetResult(true); + + // Poll until task completes + McpTask taskStatus; + do + { + await Task.Delay(100, TestContext.Current.CancellationToken); + taskStatus = await Server.GetTaskAsync(mcpTask.TaskId, TestContext.Current.CancellationToken); + } + while (taskStatus.Status == McpTaskStatus.Working || taskStatus.Status == McpTaskStatus.InputRequired); + + Assert.Equal(McpTaskStatus.Completed, taskStatus.Status); + + // Verify the result is correct + var result = await Server.GetTaskResultAsync( + mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + var textContent = Assert.IsType(Assert.Single(result.Content)); + Assert.Equal("Sampled response", textContent.Text); + } + + [Fact] + public async Task TaskAugmentedToolCall_WithMrtrElicitation_CompletesSuccessfully() + { + StartServer(); + var clientOptions = new McpClientOptions + { + ExperimentalProtocolVersion = "2026-06-XX", + Handlers = new McpClientHandlers + { + ElicitationHandler = (request, ct) => + { + return new ValueTask(new ElicitResult { Action = "confirm" }); + } + } + }; + + await using var client = await CreateMcpClientForServer(clientOptions); + + // Call the elicitation tool — MRTR resolves the elicitation request via the client handler + var result = await client.CallToolAsync("elicitation-tool", + new Dictionary { ["message"] = "Do you agree?" }, + cancellationToken: TestContext.Current.CancellationToken); + + var content = Assert.Single(result.Content); + Assert.Equal("confirm", Assert.IsType(content).Text); + } + + [Fact] + public async Task SampleAsTaskAsync_BypassesMrtrInterception() + { + // SampleAsTaskAsync sends a request with "task" metadata in the params. + // Even when MRTR context is active, these requests should go over the wire + // (they expect CreateTaskResult, not CreateMessageResult). + StartServer(); + var taskStore = new InMemoryMcpTaskStore(); + + var clientOptions = new McpClientOptions + { + ExperimentalProtocolVersion = "2026-06-XX", + TaskStore = taskStore, + Handlers = new McpClientHandlers + { + SamplingHandler = async (request, progress, ct) => + { + await Task.Delay(50, ct); + return new CreateMessageResult + { + Content = [new TextContentBlock { Text = "Task-based response" }], + Model = "test-model" + }; + } + } + }; + + await using var client = await CreateMcpClientForServer(clientOptions); + + // SampleAsTaskAsync should work normally — it sends over the wire, not through MRTR. + var mcpTask = await Server.SampleAsTaskAsync( + new CreateMessageRequestParams + { + Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Hello" }] }], + MaxTokens = 100 + }, + new McpTaskMetadata(), + TestContext.Current.CancellationToken); + + Assert.NotNull(mcpTask); + Assert.NotEmpty(mcpTask.TaskId); + Assert.Equal(McpTaskStatus.Working, mcpTask.Status); + + // Poll until task completes + McpTask taskStatus; + do + { + await Task.Delay(100, TestContext.Current.CancellationToken); + taskStatus = await Server.GetTaskAsync(mcpTask.TaskId, TestContext.Current.CancellationToken); + } + while (taskStatus.Status == McpTaskStatus.Working); + + Assert.Equal(McpTaskStatus.Completed, taskStatus.Status); + + // Retrieve and verify the result + var result = await Server.GetTaskResultAsync( + mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + var textContent = Assert.IsType(Assert.Single(result.Content)); + Assert.Equal("Task-based response", textContent.Text); + } + + [Fact] + public async Task MrtrToolCall_ThenTaskBasedSampling_BothWorkCorrectly() + { + // Verify that MRTR tool calls and task-based sampling can coexist in the same session. + StartServer(); + var taskStore = new InMemoryMcpTaskStore(); + + var clientOptions = new McpClientOptions + { + ExperimentalProtocolVersion = "2026-06-XX", + TaskStore = taskStore, + Handlers = new McpClientHandlers + { + SamplingHandler = (request, progress, ct) => + { + var text = request?.Messages[^1].Content.OfType().FirstOrDefault()?.Text; + return new ValueTask(new CreateMessageResult + { + Content = [new TextContentBlock { Text = $"Response: {text}" }], + Model = "test-model" + }); + } + } + }; + + await using var client = await CreateMcpClientForServer(clientOptions); + + // First: MRTR tool call (synchronous sampling inside a tool) + var mrtrResult = await client.CallToolAsync("sampling-tool", + new Dictionary { ["prompt"] = "MRTR test" }, + cancellationToken: TestContext.Current.CancellationToken); + + var mrtrContent = Assert.Single(mrtrResult.Content); + Assert.Equal("Response: MRTR test", Assert.IsType(mrtrContent).Text); + + // Second: Task-based sampling (goes over the wire, bypasses MRTR) + var mcpTask = await Server.SampleAsTaskAsync( + new CreateMessageRequestParams + { + Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Task test" }] }], + MaxTokens = 100 + }, + new McpTaskMetadata(), + TestContext.Current.CancellationToken); + + Assert.NotNull(mcpTask); + + // Poll until task completes + McpTask taskStatus; + do + { + await Task.Delay(100, TestContext.Current.CancellationToken); + taskStatus = await Server.GetTaskAsync(mcpTask.TaskId, TestContext.Current.CancellationToken); + } + while (taskStatus.Status == McpTaskStatus.Working); + + Assert.Equal(McpTaskStatus.Completed, taskStatus.Status); + + var taskResult = await Server.GetTaskResultAsync( + mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(taskResult); + var taskContent = Assert.IsType(Assert.Single(taskResult.Content)); + Assert.Equal("Response: Task test", taskContent.Text); + } +} From 5ac82bc450329865d4e389a1f681654e545715ac Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 19 May 2026 07:42:17 -0700 Subject: [PATCH 02/14] Apply SEP-2322 spec renames and remove ExperimentalProtocolVersion - 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> --- docs/concepts/elicitation/elicitation.md | 4 +- docs/concepts/mrtr/mrtr.md | 24 ++--- docs/concepts/roots/roots.md | 4 +- docs/concepts/sampling/sampling.md | 4 +- .../StreamableHttpHandler.cs | 3 +- .../Client/McpClientImpl.cs | 47 +++++----- .../Client/McpClientOptions.cs | 20 ---- .../McpJsonUtilities.cs | 4 +- .../McpSessionHandler.cs | 11 +-- .../Protocol/InputRequest.cs | 2 +- ...Exception.cs => InputRequiredException.cs} | 40 ++++---- ...mpleteResult.cs => InputRequiredResult.cs} | 12 +-- .../Protocol/InputResponse.cs | 2 +- .../Protocol/RequestParams.cs | 12 +-- .../Protocol/Result.cs | 4 +- .../Server/McpServer.cs | 10 +- .../Server/McpServerImpl.cs | 91 ++++++++++--------- .../Server/McpServerOptions.cs | 19 ---- .../Server/MrtrContext.cs | 2 +- tests/Common/Utils/ServerMessageTracker.cs | 12 +-- .../MapMcpTests.Mrtr.cs | 48 +++++----- .../MrtrProtocolTests.cs | 12 +-- .../McpClientDeferredTaskCreationTests.cs | 4 +- .../Client/McpClientTests.cs | 4 +- .../Client/MrtrIntegrationTests.cs | 64 ++++++------- .../Protocol/MrtrSerializationTests.cs | 16 ++-- .../Server/MrtrHandlerLifecycleTests.cs | 30 +++--- .../Server/MrtrLowLevelApiTests.cs | 12 +-- .../Server/MrtrMessageFilterTests.cs | 20 ++-- .../Server/MrtrSessionLimitTests.cs | 28 +++--- .../Server/MrtrTaskIntegrationTests.cs | 10 +- 31 files changed, 270 insertions(+), 305 deletions(-) rename src/ModelContextProtocol.Core/Protocol/{IncompleteResultException.cs => InputRequiredException.cs} (69%) rename src/ModelContextProtocol.Core/Protocol/{IncompleteResult.cs => InputRequiredResult.cs} (84%) diff --git a/docs/concepts/elicitation/elicitation.md b/docs/concepts/elicitation/elicitation.md index 4c99cc076..8e87c205b 100644 --- a/docs/concepts/elicitation/elicitation.md +++ b/docs/concepts/elicitation/elicitation.md @@ -198,7 +198,7 @@ var result = await server.ElicitAsync(new ElicitRequestParams #### Low-level API -For stateless servers or scenarios requiring manual control, throw with an elicitation input request. On retry, read the client's response from : +For stateless servers or scenarios requiring manual control, throw with an elicitation input request. On retry, read the client's response from : ```csharp [McpServerTool, Description("Tool that elicits via low-level MRTR")] @@ -221,7 +221,7 @@ public static string ElicitWithMrtr( } // First call — request user input - throw new IncompleteResultException( + throw new InputRequiredException( inputRequests: new Dictionary { ["user_input"] = InputRequest.ForElicitation(new ElicitRequestParams diff --git a/docs/concepts/mrtr/mrtr.md b/docs/concepts/mrtr/mrtr.md index 0854ef97d..87203c162 100644 --- a/docs/concepts/mrtr/mrtr.md +++ b/docs/concepts/mrtr/mrtr.md @@ -26,10 +26,10 @@ MRTR is useful when: ## How MRTR works 1. The client calls a tool on the server via `tools/call`. -2. The server tool determines it needs client input and returns an `IncompleteResult` containing `inputRequests` and/or `requestState`. +2. The server tool determines it needs client input and returns an `InputRequiredResult` containing `inputRequests` and/or `requestState`. 3. The client resolves each input request (e.g., prompts the user for elicitation, calls an LLM for sampling). 4. The client retries the original `tools/call` with `inputResponses` (keyed to the input requests) and `requestState` echoed back. -5. The server processes the responses and either returns a final result or another `IncompleteResult` for additional rounds. +5. The server processes the responses and either returns a final result or another `InputRequiredResult` for additional rounds. ## Opting in @@ -130,7 +130,7 @@ public static string MyTool( ### Returning an incomplete result -Throw to return an incomplete result to the client. The exception carries an containing `inputRequests` and/or `requestState`: +Throw to return an incomplete result to the client. The exception carries an containing `inputRequests` and/or `requestState`: ```csharp [McpServerTool, Description("Stateless tool managing its own MRTR flow")] @@ -155,7 +155,7 @@ public static string StatelessTool( } // First call — request user input - throw new IncompleteResultException( + throw new InputRequiredException( inputRequests: new Dictionary { ["user_answer"] = InputRequest.ForElicitation(new ElicitRequestParams @@ -217,7 +217,7 @@ public static string DeferredTool( // Defer work to a later retry var initialState = new MyState { Step = 1 }; - throw new IncompleteResultException( + throw new InputRequiredException( requestState: Convert.ToBase64String( JsonSerializer.SerializeToUtf8Bytes(initialState))); } @@ -227,7 +227,7 @@ The client automatically retries `requestState`-only incomplete results, echoing ### Multiple round trips -A tool can perform multiple rounds of interaction by throwing `IncompleteResultException` multiple times across retries: +A tool can perform multiple rounds of interaction by throwing `InputRequiredException` multiple times across retries: ```csharp [McpServerTool, Description("Multi-step wizard")] @@ -250,7 +250,7 @@ public static string WizardTool( var name = inputResponses["name"].ElicitationResult?.Content?.FirstOrDefault().Value; // Second round — ask for age - throw new IncompleteResultException( + throw new InputRequiredException( inputRequests: new Dictionary { ["age"] = InputRequest.ForElicitation(new ElicitRequestParams @@ -277,7 +277,7 @@ public static string WizardTool( } // First round — ask for name - throw new IncompleteResultException( + throw new InputRequiredException( inputRequests: new Dictionary { ["name"] = InputRequest.ForElicitation(new ElicitRequestParams @@ -332,7 +332,7 @@ When a server has MRTR enabled but the connected client does not: ### Backward compatibility for MRTR-native tools -Tools written with the low-level MRTR pattern (`IncompleteResultException`) 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. +Tools written with the low-level MRTR pattern (`InputRequiredException`) work automatically with clients that don't support MRTR. When a tool throws `InputRequiredException` 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. This means you can write a single tool implementation using the MRTR-native pattern and it will work with any client: @@ -350,7 +350,7 @@ public static string GetWeather( } // First call: request the user's preferred units - throw new IncompleteResultException( + throw new InputRequiredException( inputRequests: new Dictionary { ["units"] = InputRequest.ForElicitation(new ElicitRequestParams @@ -363,8 +363,8 @@ public static string GetWeather( } ``` -- **With an MRTR client**: The `IncompleteResult` is sent over the wire. The client resolves the elicitation and retries with `inputResponses`. -- **Without MRTR**: The SDK sends a standard `elicitation/create` JSON-RPC request to the client, collects the response, and retries the handler internally. The client never sees the `IncompleteResult`. +- **With an MRTR client**: The `InputRequiredResult` is sent over the wire. The client resolves the elicitation and retries with `inputResponses`. +- **Without MRTR**: The SDK sends a standard `elicitation/create` JSON-RPC request to the client, collects the response, and retries the handler internally. The client never sees the `InputRequiredResult`. > [!NOTE] > The backcompat retry loop resolves up to 10 rounds. Tools that need more rounds should use the high-level API (`ElicitAsync`) instead. diff --git a/docs/concepts/roots/roots.md b/docs/concepts/roots/roots.md index 65c45d91b..c2cc4efb6 100644 --- a/docs/concepts/roots/roots.md +++ b/docs/concepts/roots/roots.md @@ -123,7 +123,7 @@ foreach (var root in result.Roots) #### Low-level API -For stateless servers or scenarios requiring manual control, throw with a roots input request. On retry, read the client's response from : +For stateless servers or scenarios requiring manual control, throw with a roots input request. On retry, read the client's response from : ```csharp [McpServerTool, Description("Tool that requests roots via low-level MRTR")] @@ -144,7 +144,7 @@ public static string ListRootsWithMrtr( } // First call — request the client's root list - throw new IncompleteResultException( + throw new InputRequiredException( inputRequests: new Dictionary { ["get_roots"] = InputRequest.ForRootsList(new ListRootsRequestParams()) diff --git a/docs/concepts/sampling/sampling.md b/docs/concepts/sampling/sampling.md index 03397ddca..339a91c66 100644 --- a/docs/concepts/sampling/sampling.md +++ b/docs/concepts/sampling/sampling.md @@ -149,7 +149,7 @@ var result = await server.SampleAsync( #### Low-level API -For stateless servers or scenarios requiring manual control, throw with a sampling input request. On retry, read the client's response from : +For stateless servers or scenarios requiring manual control, throw with a sampling input request. On retry, read the client's response from : ```csharp [McpServerTool, Description("Tool that samples via low-level MRTR")] @@ -171,7 +171,7 @@ public static string SampleWithMrtr( } // First call — request LLM completion from the client - throw new IncompleteResultException( + throw new InputRequiredException( inputRequests: new Dictionary { ["llm_call"] = InputRequest.ForSampling(new CreateMessageRequestParams diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index b326018ad..2de9407b1 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -552,8 +552,7 @@ private bool ValidateProtocolVersionHeader(HttpContext context, out string? erro { var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); if (!string.IsNullOrEmpty(protocolVersionHeader) && - !s_supportedProtocolVersions.Contains(protocolVersionHeader) && - !(mcpServerOptionsSnapshot.Value.ExperimentalProtocolVersion is { } experimentalVersion && protocolVersionHeader == experimentalVersion)) + !s_supportedProtocolVersions.Contains(protocolVersionHeader)) { errorMessage = $"Bad Request: The MCP-Protocol-Version header value '{protocolVersionHeader}' is not supported."; return false; diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index 521fd51ee..55e946902 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -650,7 +650,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) try { // Send initialize request - string requestProtocol = _options.ProtocolVersion ?? _options.ExperimentalProtocolVersion ?? McpSessionHandler.LatestProtocolVersion; + string requestProtocol = _options.ProtocolVersion ?? McpSessionHandler.LatestProtocolVersion; var initializeResponse = await SendRequestAsync( RequestMethods.Initialize, new InitializeRequestParams @@ -678,8 +678,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) // Validate protocol version bool isResponseProtocolValid = _options.ProtocolVersion is { } optionsProtocol ? optionsProtocol == initializeResponse.ProtocolVersion : - McpSessionHandler.SupportedProtocolVersions.Contains(initializeResponse.ProtocolVersion) || - (_options.ExperimentalProtocolVersion is not null && _options.ExperimentalProtocolVersion == initializeResponse.ProtocolVersion); + McpSessionHandler.SupportedProtocolVersions.Contains(initializeResponse.ProtocolVersion); if (!isResponseProtocolValid) { LogServerProtocolVersionMismatch(_endpointName, requestProtocol, initializeResponse.ProtocolVersion); @@ -832,17 +831,17 @@ request.Params is System.Text.Json.Nodes.JsonObject paramsObjForHeaders && { JsonRpcResponse response = await _sessionHandler.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); - // Check if the result is an IncompleteResult by looking at result_type. + // Check if the result is an InputRequiredResult by looking at result_type. if (response.Result is JsonObject resultObj && - resultObj.TryGetPropertyValue("result_type", out var resultTypeNode) && - resultTypeNode?.GetValue() is "incomplete") + resultObj.TryGetPropertyValue("resultType", out var resultTypeNode) && + resultTypeNode?.GetValue() is "input_required") { - WarnIfIncompleteResultOnNonMrtrSession(request.Method); + WarnIfInputRequiredResultOnNonMrtrSession(request.Method); - var incompleteResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.JsonContext.Default.IncompleteResult) - ?? throw new JsonException("Failed to deserialize IncompleteResult."); + var InputRequiredResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.JsonContext.Default.InputRequiredResult) + ?? throw new JsonException("Failed to deserialize InputRequiredResult."); - if (incompleteResult.InputRequests is { Count: > 0 } inputRequests) + if (InputRequiredResult.InputRequests is { Count: > 0 } inputRequests) { IDictionary inputResponses = await ResolveInputRequestsAsync(inputRequests, cancellationToken).ConfigureAwait(false); @@ -853,25 +852,25 @@ request.Params is System.Text.Json.Nodes.JsonObject paramsObjForHeaders && paramsObj["inputResponses"] = JsonSerializer.SerializeToNode( inputResponses, McpJsonUtilities.JsonContext.Default.IDictionaryStringInputResponse); - if (incompleteResult.RequestState is { } requestState) + if (InputRequiredResult.RequestState is { } requestState) { paramsObj["requestState"] = requestState; } request = new JsonRpcRequest { Method = request.Method, Params = paramsObj, Context = request.Context }; } - else if (incompleteResult.RequestState is not null) + else if (InputRequiredResult.RequestState is not null) { // No input requests but has requestState (e.g., load shedding) — just retry with state. var paramsObj = request.Params?.DeepClone() as JsonObject ?? new JsonObject(); - paramsObj["requestState"] = incompleteResult.RequestState; + paramsObj["requestState"] = InputRequiredResult.RequestState; paramsObj.Remove("inputResponses"); request = new JsonRpcRequest { Method = request.Method, Params = paramsObj, Context = request.Context }; } else { - throw new McpException("Server returned an IncompleteResult without inputRequests or requestState."); + throw new McpException("Server returned an InputRequiredResult without inputRequests or requestState."); } continue; // retry with the updated request @@ -880,7 +879,7 @@ request.Params is System.Text.Json.Nodes.JsonObject paramsObjForHeaders && return response; } - throw new McpException($"Server returned IncompleteResult more than {maxRetries} times."); + throw new McpException($"Server returned InputRequiredResult more than {maxRetries} times."); } /// @@ -919,28 +918,26 @@ public override async ValueTask DisposeAsync() /// Logs a warning if the session negotiated MRTR but the server sent a legacy JSON-RPC request. private void WarnIfLegacyRequestOnMrtrSession(string method) { - if (_options.ExperimentalProtocolVersion is not null && - _negotiatedProtocolVersion == _options.ExperimentalProtocolVersion) + if (_negotiatedProtocolVersion == McpSessionHandler.DraftProtocolVersion) { LogLegacyRequestOnMrtrSession(_endpointName, method); } } - /// Logs a warning if the session did not negotiate MRTR but the server sent an IncompleteResult. - private void WarnIfIncompleteResultOnNonMrtrSession(string method) + /// Logs a warning if the session did not negotiate MRTR but the server sent an InputRequiredResult. + private void WarnIfInputRequiredResultOnNonMrtrSession(string method) { - if (_options.ExperimentalProtocolVersion is null || - _negotiatedProtocolVersion != _options.ExperimentalProtocolVersion) + if (_negotiatedProtocolVersion != McpSessionHandler.DraftProtocolVersion) { - LogIncompleteResultOnNonMrtrSession(_endpointName, method, _negotiatedProtocolVersion); + LogInputRequiredResultOnNonMrtrSession(_endpointName, method, _negotiatedProtocolVersion); } } - [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} received legacy '{Method}' JSON-RPC request on session that negotiated MRTR. The server should use IncompleteResult instead of sending direct requests.")] + [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} received legacy '{Method}' JSON-RPC request on session that negotiated MRTR. The server should use InputRequiredResult instead of sending direct requests.")] private partial void LogLegacyRequestOnMrtrSession(string endpointName, string method); - [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} received IncompleteResult for '{Method}' on session that did not negotiate MRTR (protocol version '{ProtocolVersion}'). The server may not be spec-compliant.")] - private partial void LogIncompleteResultOnNonMrtrSession(string endpointName, string method, string? protocolVersion); + [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} received InputRequiredResult for '{Method}' on session that did not negotiate MRTR (protocol version '{ProtocolVersion}'). The server may not be spec-compliant.")] + private partial void LogInputRequiredResultOnNonMrtrSession(string endpointName, string method, string? protocolVersion); [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} client received server '{ServerInfo}' capabilities: '{Capabilities}'.")] private partial void LogServerCapabilitiesReceived(string endpointName, string capabilities, string serverInfo); diff --git a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs index 3c088fdb3..6d91f5b03 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs @@ -111,24 +111,4 @@ public McpClientHandlers Handlers /// [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] public bool SendTaskStatusNotifications { get; set; } = true; - - /// - /// Gets or sets an experimental protocol version that enables draft protocol features such as - /// Multi Round-Trip Requests (MRTR). - /// - /// - /// - /// When set, this version is used as the requested protocol version during initialization instead of - /// the latest stable version. The server must also have a matching ExperimentalProtocolVersion - /// configured for the experimental features to activate. If the server does not recognize the - /// experimental version, it will negotiate to the latest stable version and the client will work - /// normally without experimental features. - /// - /// - /// This property is intended for proof-of-concept and testing of draft MCP specification features - /// that have not yet been ratified. - /// - /// - [Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] - public string? ExperimentalProtocolVersion { get; set; } } diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index daf738062..b4613d9f2 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI; using ModelContextProtocol.Authentication; using ModelContextProtocol.Protocol; using System.Diagnostics.CodeAnalysis; @@ -145,7 +145,7 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSerializable(typeof(UnsubscribeRequestParams))] // MCP MRTR (Multi Round-Trip Requests) - [JsonSerializable(typeof(IncompleteResult))] + [JsonSerializable(typeof(InputRequiredResult))] [JsonSerializable(typeof(InputRequest))] [JsonSerializable(typeof(InputResponse))] [JsonSerializable(typeof(IDictionary))] diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index 6ee7a4f11..fd32c8286 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -32,12 +32,11 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable internal const string LatestProtocolVersion = "2025-11-25"; /// - /// The experimental protocol version that enables MRTR (Multi Round-Trip Requests). - /// This version is not in and is only accepted - /// when or - /// is set to this value. + /// The draft protocol version that enables MRTR (Multi Round-Trip Requests) per SEP-2322. + /// Clients and servers opt in by setting + /// or to this value. /// - internal const string ExperimentalProtocolVersion = "2026-06-XX"; + internal const string DraftProtocolVersion = "DRAFT-2026-v1"; /// /// All protocol versions supported by this implementation. @@ -49,7 +48,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable "2025-03-26", "2025-06-18", LatestProtocolVersion, - "DRAFT-2026-v1", + DraftProtocolVersion, ]; /// diff --git a/src/ModelContextProtocol.Core/Protocol/InputRequest.cs b/src/ModelContextProtocol.Core/Protocol/InputRequest.cs index e87551427..bd9161423 100644 --- a/src/ModelContextProtocol.Core/Protocol/InputRequest.cs +++ b/src/ModelContextProtocol.Core/Protocol/InputRequest.cs @@ -12,7 +12,7 @@ namespace ModelContextProtocol.Protocol; /// /// An wraps a server-to-client request such as /// , , -/// or . It is included in an +/// or . It is included in an /// when the server needs additional input before it can complete a client-initiated request. /// /// diff --git a/src/ModelContextProtocol.Core/Protocol/IncompleteResultException.cs b/src/ModelContextProtocol.Core/Protocol/InputRequiredException.cs similarity index 69% rename from src/ModelContextProtocol.Core/Protocol/IncompleteResultException.cs rename to src/ModelContextProtocol.Core/Protocol/InputRequiredException.cs index 8ee439e4a..eec7ccf29 100644 --- a/src/ModelContextProtocol.Core/Protocol/IncompleteResultException.cs +++ b/src/ModelContextProtocol.Core/Protocol/InputRequiredException.cs @@ -3,23 +3,23 @@ namespace ModelContextProtocol.Protocol; /// -/// The exception that is thrown by a server handler to return an +/// The exception that is thrown by a server handler to return an /// to the client, signaling that additional input is needed before the request can be completed. /// /// /// /// This exception is part of the low-level Multi Round-Trip Requests (MRTR) API. Tool handlers -/// throw this exception to directly control the incomplete result payload, including -/// and . +/// throw this exception to directly control the input-required result payload, including +/// and . /// /// /// For stateless servers, this enables multi-round-trip flows without requiring the handler to stay -/// alive between round trips. The server encodes its state in +/// alive between round trips. The server encodes its state in /// and receives it back on retry via . /// /// /// To return a requestState-only response (e.g., for load shedding), omit -/// and set only . +/// and set only . /// The client will retry the request with the state echoed back. /// /// @@ -45,7 +45,7 @@ namespace ModelContextProtocol.Protocol; /// return "This tool requires MRTR support."; /// } /// -/// throw new IncompleteResultException( +/// throw new InputRequiredException( /// inputRequests: new Dictionary<string, InputRequest> /// { /// ["user_input"] = InputRequest.ForElicitation(new ElicitRequestParams { ... }) @@ -55,22 +55,22 @@ namespace ModelContextProtocol.Protocol; /// /// [Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] -public class IncompleteResultException : Exception +public class InputRequiredException : Exception { /// - /// Initializes a new instance of the class - /// with the specified . + /// Initializes a new instance of the class + /// with the specified . /// - /// The incomplete result to return to the client. - public IncompleteResultException(IncompleteResult incompleteResult) - : base("The server returned an incomplete result requiring additional client input.") + /// The input-required result to return to the client. + public InputRequiredException(InputRequiredResult result) + : base("The server returned an input-required result requiring additional client input.") { - Throw.IfNull(incompleteResult); - IncompleteResult = incompleteResult; + Throw.IfNull(result); + Result = result; } /// - /// Initializes a new instance of the class + /// Initializes a new instance of the class /// with the specified input requests and/or request state. /// /// @@ -85,17 +85,17 @@ public IncompleteResultException(IncompleteResult incompleteResult) /// Both and are . /// At least one must be provided. /// - public IncompleteResultException( + public InputRequiredException( IDictionary? inputRequests = null, string? requestState = null) - : base("The server returned an incomplete result requiring additional client input.") + : base("The server returned an input-required result requiring additional client input.") { if (inputRequests is null && requestState is null) { throw new ArgumentException("At least one of inputRequests or requestState must be provided."); } - IncompleteResult = new IncompleteResult + Result = new InputRequiredResult { InputRequests = inputRequests, RequestState = requestState, @@ -103,7 +103,7 @@ public IncompleteResultException( } /// - /// Gets the incomplete result to return to the client. + /// Gets the input-required result to return to the client. /// - public IncompleteResult IncompleteResult { get; } + public InputRequiredResult Result { get; } } diff --git a/src/ModelContextProtocol.Core/Protocol/IncompleteResult.cs b/src/ModelContextProtocol.Core/Protocol/InputRequiredResult.cs similarity index 84% rename from src/ModelContextProtocol.Core/Protocol/IncompleteResult.cs rename to src/ModelContextProtocol.Core/Protocol/InputRequiredResult.cs index a54a06dd0..006fd5244 100644 --- a/src/ModelContextProtocol.Core/Protocol/IncompleteResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/InputRequiredResult.cs @@ -4,12 +4,12 @@ namespace ModelContextProtocol.Protocol; /// -/// Represents an incomplete result sent by the server to indicate that additional input is needed +/// Represents an input-required result sent by the server to indicate that additional input is needed /// before the request can be completed. /// /// /// -/// An is returned in response to a client-initiated request (such as +/// An is returned in response to a client-initiated request (such as /// or ) when the server /// needs the client to fulfill one or more server-initiated requests before it can produce a final result. /// @@ -21,14 +21,14 @@ namespace ModelContextProtocol.Protocol; /// /// [Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] -public sealed class IncompleteResult : Result +public sealed class InputRequiredResult : Result { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// - public IncompleteResult() + public InputRequiredResult() { - ResultType = "incomplete"; + ResultType = "input_required"; } /// diff --git a/src/ModelContextProtocol.Core/Protocol/InputResponse.cs b/src/ModelContextProtocol.Core/Protocol/InputResponse.cs index b9e99002e..e6db80d3a 100644 --- a/src/ModelContextProtocol.Core/Protocol/InputResponse.cs +++ b/src/ModelContextProtocol.Core/Protocol/InputResponse.cs @@ -17,7 +17,7 @@ namespace ModelContextProtocol.Protocol; /// /// /// The input response does not carry its own type discriminator in JSON. The type is determined by -/// the corresponding key in the map. +/// the corresponding key in the map. /// /// [Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] diff --git a/src/ModelContextProtocol.Core/Protocol/RequestParams.cs b/src/ModelContextProtocol.Core/Protocol/RequestParams.cs index 4ba1a7093..004f1711f 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestParams.cs @@ -27,12 +27,12 @@ private protected RequestParams() public JsonObject? Meta { get; set; } /// - /// Gets or sets the responses to server-initiated input requests from a previous . + /// Gets or sets the responses to server-initiated input requests from a previous . /// /// /// - /// This property is populated when retrying a request after receiving an . - /// Each key corresponds to a key from the map, and + /// This property is populated when retrying a request after receiving an . + /// Each key corresponds to a key from the map, and /// the value is the client's response to that input request. /// /// @@ -50,12 +50,12 @@ public IDictionary? InputResponses internal IDictionary? InputResponsesCore { get; set; } /// - /// Gets or sets opaque request state echoed back from a previous . + /// Gets or sets opaque request state echoed back from a previous . /// /// /// - /// This property is populated when retrying a request after receiving an - /// that included a value. The client must echo back the + /// This property is populated when retrying a request after receiving an + /// that included a value. The client must echo back the /// exact value without modification. /// /// diff --git a/src/ModelContextProtocol.Core/Protocol/Result.cs b/src/ModelContextProtocol.Core/Protocol/Result.cs index 9b4531414..6e43249a1 100644 --- a/src/ModelContextProtocol.Core/Protocol/Result.cs +++ b/src/ModelContextProtocol.Core/Protocol/Result.cs @@ -28,11 +28,11 @@ private protected Result() /// /// /// When absent or set to "complete", the result is a normal completed response. - /// When set to "incomplete", the result is an indicating + /// When set to "input_required", the result is an indicating /// that additional input is needed before the request can be completed. /// /// /// Defaults to , which is equivalent to "complete". - [JsonPropertyName("result_type")] + [JsonPropertyName("resultType")] public string? ResultType { get; set; } } diff --git a/src/ModelContextProtocol.Core/Server/McpServer.cs b/src/ModelContextProtocol.Core/Server/McpServer.cs index 049799c77..f2a78a561 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.cs @@ -70,14 +70,14 @@ protected McpServer() /// /// /// When this property returns , tool handlers can throw - /// to return an - /// with and/or - /// to the client. + /// to return an + /// with and/or + /// to the client. /// /// /// When this property returns , tool handlers should provide a fallback /// experience (for example, returning a text message explaining that the client does not support - /// the required feature) instead of throwing . + /// the required feature) instead of throwing . /// /// [Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] @@ -95,7 +95,7 @@ protected McpServer() /// /// Before calling this method, /// and use the ephemeral - /// MRTR mechanism (returning to the client). After calling this method, + /// MRTR mechanism (returning to the client). After calling this method, /// the task is created and subsequent calls use the persistent workflow (task status /// with tasks/result and tasks/input_response). /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index afb1a40ae..2eb62cd61 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -268,11 +268,9 @@ private void ConfigureInitialize(McpServerOptions options) // Negotiate a protocol version. If the server options provide one, use that. // Otherwise, try to use whatever the client requested as long as it's supported. // If it's not supported, fall back to the latest supported version. - // Also accept the experimental protocol version when the server has it configured. string? protocolVersion = options.ProtocolVersion; protocolVersion ??= request?.ProtocolVersion is string clientProtocolVersion && - (McpSessionHandler.SupportedProtocolVersions.Contains(clientProtocolVersion) || - (options.ExperimentalProtocolVersion is not null && clientProtocolVersion == options.ExperimentalProtocolVersion)) ? + McpSessionHandler.SupportedProtocolVersions.Contains(clientProtocolVersion) ? clientProtocolVersion : McpSessionHandler.LatestProtocolVersion; @@ -845,14 +843,14 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) // is signaled — tool handler cancellation is an expected lifecycle event // (client request cancellation, session shutdown, MRTR teardown), not a // tool error. - // Skip logging for IncompleteResultException — it's normal MRTR control flow, - // not an error (the low-level API uses it to signal an IncompleteResult). - if (!(e is OperationCanceledException && cancellationToken.IsCancellationRequested) && e is not IncompleteResultException) + // Skip logging for InputRequiredException — it's normal MRTR control flow, + // not an error (the low-level API uses it to signal an InputRequiredResult). + if (!(e is OperationCanceledException && cancellationToken.IsCancellationRequested) && e is not InputRequiredException) { ToolCallError(request.Params?.Name ?? string.Empty, e); } - if ((e is OperationCanceledException && cancellationToken.IsCancellationRequested) || e is McpProtocolException || e is IncompleteResultException) + if ((e is OperationCanceledException && cancellationToken.IsCancellationRequested) || e is McpProtocolException || e is InputRequiredException) { throw; } @@ -1199,18 +1197,28 @@ internal static LoggingLevel ToLoggingLevel(LogLevel level) => }; /// - /// Checks whether the negotiated protocol version enables MRTR. + /// Checks whether the negotiated protocol version enables MRTR per SEP-2322 (DRAFT-2026-v1). /// internal bool ClientSupportsMrtr() => + _negotiatedProtocolVersion == McpSessionHandler.DraftProtocolVersion; + + /// + /// Returns when the session has negotiated a pre-DRAFT-2026-v1 protocol + /// version on a stateful transport (i.e., a transport that supports Mcp-Session-Id). These sessions + /// keep the implicit MRTR behavior where a handler can call ElicitAsync/SampleAsync + /// and the SDK suspends/resumes the handler across an round trip. + /// + internal bool IsLegacyStatefulSession() => _negotiatedProtocolVersion is not null && - _negotiatedProtocolVersion == ServerOptions.ExperimentalProtocolVersion; + _negotiatedProtocolVersion != McpSessionHandler.DraftProtocolVersion && + _sessionTransport is not StreamableHttpServerTransport { Stateless: true }; /// - /// Checks whether the low-level MRTR API () is available + /// Checks whether the low-level MRTR API () is available /// for the current request. Returns in all cases except stateless mode /// with a client that hasn't negotiated MRTR — that's the one configuration where nobody can /// drive the retry loop (the server can't send JSON-RPC requests to the client, and the client - /// doesn't know about IncompleteResult). + /// doesn't know about InputRequiredResult). /// internal bool IsLowLevelMrtrAvailable() => ClientSupportsMrtr() || @@ -1218,12 +1226,12 @@ internal bool IsLowLevelMrtrAvailable() => /// /// Wraps MRTR-eligible request handlers so that when a handler calls ElicitAsync/SampleAsync, - /// an IncompleteResult is returned early and the handler is suspended until the retry arrives. + /// an InputRequiredResult is returned early and the handler is suspended until the retry arrives. /// private void ConfigureMrtr() { // Wrap all methods that may trigger MRTR (server calling ElicitAsync/SampleAsync/RequestRootsAsync - // during handler execution). These methods may produce IncompleteResult if the handler needs input. + // during handler execution). These methods may produce InputRequiredResult if the handler needs input. WrapHandlerWithMrtr(RequestMethods.ToolsCall); WrapHandlerWithMrtr(RequestMethods.PromptsGet); WrapHandlerWithMrtr(RequestMethods.ResourcesRead); @@ -1231,7 +1239,7 @@ private void ConfigureMrtr() /// /// Replaces an existing request handler entry with an MRTR-aware wrapper that supports - /// handler suspension and IncompleteResult responses. + /// handler suspension and InputRequiredResult responses. /// private void WrapHandlerWithMrtr(string method) { @@ -1301,11 +1309,12 @@ private void WrapHandlerWithMrtr(string method) // high-level handlers that call ElicitAsync/SampleAsync. } - // Not a retry, or a retry without a continuation - check if the client supports MRTR - // and the server is stateful (the high-level await path requires storing continuations). - if (!ClientSupportsMrtr() || _sessionTransport is StreamableHttpServerTransport { Stateless: true }) + // Implicit MRTR (handler suspension across ElicitAsync/SampleAsync) is reserved for + // legacy stateful sessions only. DRAFT-2026-v1 sessions always go through the exception + // path (the client drives the retry loop). Stateless sessions also use the exception path. + if (!IsLegacyStatefulSession()) { - return await InvokeWithIncompleteResultHandlingAsync(originalHandler, request, cancellationToken).ConfigureAwait(false); + return await InvokeWithInputRequiredResultHandlingAsync(originalHandler, request, cancellationToken).ConfigureAwait(false); } // Start a new MRTR-aware handler invocation. @@ -1348,13 +1357,13 @@ private void WrapHandlerWithMrtr(string method) } /// - /// Invokes a handler and catches to convert it to an - /// JSON response. When MRTR is negotiated or the server is stateless, + /// Invokes a handler and catches to convert it to an + /// JSON response. When MRTR is negotiated or the server is stateless, /// the result is serialized directly. Otherwise, input requests are resolved via standard JSON-RPC /// calls (elicitation, sampling, roots) and the handler is retried with the responses — allowing /// MRTR-native tools to work transparently with clients that don't support MRTR. /// - private async Task InvokeWithIncompleteResultHandlingAsync( + private async Task InvokeWithInputRequiredResultHandlingAsync( Func> handler, JsonRpcRequest request, CancellationToken cancellationToken) @@ -1367,18 +1376,18 @@ private void WrapHandlerWithMrtr(string method) { return await handler(request, cancellationToken).ConfigureAwait(false); } - catch (IncompleteResultException ex) + catch (InputRequiredException ex) { // If the client natively supports MRTR, serialize and return directly — // the client will drive the retry loop. if (ClientSupportsMrtr()) { - return SerializeIncompleteResult(ex.IncompleteResult); + return SerializeInputRequiredResult(ex.Result); } // In stateless mode without MRTR, the server can't resolve input requests via // JSON-RPC (no persistent session for server-to-client requests), and the client - // won't recognize the IncompleteResult. This is the one unsupported configuration. + // won't recognize the InputRequiredResult. This is the one unsupported configuration. if (_sessionTransport is StreamableHttpServerTransport { Stateless: true }) { throw new McpException( @@ -1387,7 +1396,7 @@ private void WrapHandlerWithMrtr(string method) } // Backcompat: resolve input requests via standard JSON-RPC calls and retry the handler. - if (ex.IncompleteResult.InputRequests is not { Count: > 0 } inputRequests) + if (ex.Result.InputRequests is not { Count: > 0 } inputRequests) { throw new McpException( "A tool handler returned an incomplete result without input requests, and the client does not support MRTR.", ex); @@ -1411,7 +1420,7 @@ private void WrapHandlerWithMrtr(string method) paramsObj["inputResponses"] = JsonSerializer.SerializeToNode( (IDictionary)inputResponses, McpJsonUtilities.JsonContext.Default.IDictionaryStringInputResponse); - if (ex.IncompleteResult.RequestState is { } requestState) + if (ex.Result.RequestState is { } requestState) { paramsObj["requestState"] = requestState; } @@ -1461,8 +1470,8 @@ private async Task ResolveInputRequestAsync(InputRequest inputReq /// /// Awaits the outcome of an MRTR-enabled handler invocation. /// If the handler completes, returns its result. If an exchange arrives (handler needs input), - /// builds and returns an IncompleteResult and stores the continuation for future retries. - /// If the handler throws , the result is returned directly + /// builds and returns an InputRequiredResult and stores the continuation for future retries. + /// If the handler throws , the result is returned directly /// without storing a continuation (low-level MRTR path). /// When deferred task creation is enabled, also races against the task creation signal. /// @@ -1494,8 +1503,8 @@ private async Task ResolveInputRequestAsync(InputRequest inputReq if (completedTask == handlerTask) { - // Handler completed - return its result, propagate its exception, or handle IncompleteResultException. - return await AwaitHandlerWithIncompleteResultHandlingAsync(handlerTask).ConfigureAwait(false); + // Handler completed - return its result, propagate its exception, or handle InputRequiredException. + return await AwaitHandlerWithInputRequiredResultHandlingAsync(handlerTask).ConfigureAwait(false); } if (deferredTask is not null && completedTask == deferredTask.SignalTask) @@ -1508,7 +1517,7 @@ private async Task ResolveInputRequestAsync(InputRequest inputReq var exchange = await exchangeTask.ConfigureAwait(false); var correlationId = Guid.NewGuid().ToString("N"); - var incompleteResult = new IncompleteResult + var InputRequiredResult = new InputRequiredResult { InputRequests = new Dictionary { [exchange.Key] = exchange.InputRequest }, RequestState = correlationId, @@ -1518,7 +1527,7 @@ private async Task ResolveInputRequestAsync(InputRequest inputReq continuation.PendingExchange = exchange; _mrtrContinuations[correlationId] = continuation; - return SerializeIncompleteResult(incompleteResult); + return SerializeInputRequiredResult(InputRequiredResult); } /// @@ -1536,9 +1545,9 @@ private async Task ObserveHandlerCompletionAsync(Task handlerTask) { // Handler cancelled — expected lifecycle event (disposal, client cancel, session shutdown). } - catch (IncompleteResultException) + catch (InputRequiredException) { - // Low-level MRTR: handler explicitly signaling an IncompleteResult. Not an error. + // Low-level MRTR: handler explicitly signaling an InputRequiredResult. Not an error. } catch (Exception ex) { @@ -1554,23 +1563,23 @@ private async Task ObserveHandlerCompletionAsync(Task handlerTask) } /// - /// Awaits a handler task, catching to convert it to an - /// JSON response without storing a continuation. + /// Awaits a handler task, catching to convert it to an + /// JSON response without storing a continuation. /// - private static async Task AwaitHandlerWithIncompleteResultHandlingAsync(Task handlerTask) + private static async Task AwaitHandlerWithInputRequiredResultHandlingAsync(Task handlerTask) { try { return await handlerTask.ConfigureAwait(false); } - catch (IncompleteResultException ex) + catch (InputRequiredException ex) { - return SerializeIncompleteResult(ex.IncompleteResult); + return SerializeInputRequiredResult(ex.Result); } } - private static JsonNode? SerializeIncompleteResult(IncompleteResult incompleteResult) => - JsonSerializer.SerializeToNode(incompleteResult, McpJsonUtilities.JsonContext.Default.IncompleteResult); + private static JsonNode? SerializeInputRequiredResult(InputRequiredResult InputRequiredResult) => + JsonSerializer.SerializeToNode(InputRequiredResult, McpJsonUtilities.JsonContext.Default.InputRequiredResult); /// /// Handles the transition from ephemeral MRTR to task-based execution when the handler diff --git a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs index 0f12c253c..6da8bbfbe 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs @@ -238,23 +238,4 @@ public McpServerFilters Filters /// [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] public bool SendTaskStatusNotifications { get; set; } - - /// - /// Gets or sets an experimental protocol version that enables draft protocol features such as - /// Multi Round-Trip Requests (MRTR). - /// - /// - /// - /// When set, this version is accepted from clients during protocol version negotiation, and MRTR - /// is activated when the negotiated version matches. If a client does not request this version, - /// the server negotiates to the latest stable version and uses standard server-to-client JSON-RPC - /// requests for sampling and elicitation. - /// - /// - /// This property is intended for proof-of-concept and testing of draft MCP specification features - /// that have not yet been ratified. - /// - /// - [Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] - public string? ExperimentalProtocolVersion { get; set; } } diff --git a/src/ModelContextProtocol.Core/Server/MrtrContext.cs b/src/ModelContextProtocol.Core/Server/MrtrContext.cs index 85bc7c8df..38ad349ff 100644 --- a/src/ModelContextProtocol.Core/Server/MrtrContext.cs +++ b/src/ModelContextProtocol.Core/Server/MrtrContext.cs @@ -8,7 +8,7 @@ namespace ModelContextProtocol.Server; /// , /// the handler sets the exchange TCS and suspends on a response TCS. The pipeline detects the exchange /// via or the task returned by , -/// sends an , and later completes the response TCS when the retry arrives. +/// sends an , and later completes the response TCS when the retry arrives. /// internal sealed class MrtrContext { diff --git a/tests/Common/Utils/ServerMessageTracker.cs b/tests/Common/Utils/ServerMessageTracker.cs index c12de1e08..f58a1a9be 100644 --- a/tests/Common/Utils/ServerMessageTracker.cs +++ b/tests/Common/Utils/ServerMessageTracker.cs @@ -29,7 +29,7 @@ internal sealed class ServerMessageTracker /// public void AddFilters(McpMessageFilters messageFilters) { - // Track outgoing legacy JSON-RPC requests and IncompleteResult responses. + // Track outgoing legacy JSON-RPC requests and InputRequiredResult responses. messageFilters.OutgoingFilters.Add(next => async (context, cancellationToken) => { if (context.JsonRpcMessage is JsonRpcRequest request && LegacyMrtrMethods.Contains(request.Method)) @@ -38,8 +38,8 @@ public void AddFilters(McpMessageFilters messageFilters) } else if (context.JsonRpcMessage is JsonRpcResponse response && response.Result is JsonObject resultObj && - resultObj.TryGetPropertyValue("result_type", out var resultTypeNode) && - resultTypeNode?.GetValue() == "incomplete") + resultObj.TryGetPropertyValue("resultType", out var resultTypeNode) && + resultTypeNode?.GetValue() == "input_required") { Interlocked.Increment(ref _incompleteResultCount); } @@ -62,19 +62,19 @@ request.Params is JsonObject paramsObj && } /// - /// Asserts that MRTR was used: at least one IncompleteResult response was sent + /// Asserts that MRTR was used: at least one InputRequiredResult response was sent /// and no legacy JSON-RPC requests (elicitation/create, sampling/createMessage, roots/list) were sent. /// public void AssertMrtrUsed() { Assert.True(_incompleteResultCount > 0, - "Expected at least one IncompleteResult response (MRTR mode), but none were detected."); + "Expected at least one InputRequiredResult response (MRTR mode), but none were detected."); Assert.Empty(_legacyRequestMethods); } /// /// Asserts that legacy mode was used: at least one legacy JSON-RPC request was sent - /// and no MRTR retries or IncompleteResult responses were detected. + /// and no MRTR retries or InputRequiredResult responses were detected. /// public void AssertMrtrNotUsed() { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs index e026fff73..ea6ded62f 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs @@ -16,7 +16,7 @@ private ServerMessageTracker ConfigureExperimentalServer(params Delegate[] tools Builder.Services.AddMcpServer(options => { options.ServerInfo = new Implementation { Name = "ExperimentalServer", Version = "1" }; - options.ExperimentalProtocolVersion = "2026-06-XX"; + options.ProtocolVersion = "DRAFT-2026-v1"; messageTracker.AddFilters(options.Filters.Message); }) .WithHttpTransport(ConfigureStateless) @@ -41,7 +41,7 @@ private Task ConnectExperimentalAsync() => ConnectAsync(configureClient: options => { ConfigureMrtrHandlers(options); - options.ExperimentalProtocolVersion = "2026-06-XX"; + options.ProtocolVersion = "DRAFT-2026-v1"; }); private Task ConnectDefaultAsync() => @@ -90,7 +90,7 @@ private static void ConfigureMrtrHandlers(McpClientOptions options) // ===================================================================== // MRTR tests: experimental (native), backcompat (legacy JSON-RPC), and edge cases. - // Each test creates its own server with ExperimentalProtocolVersion enabled. + // Each test creates its own server with DRAFT-2026-v1 enabled. // ===================================================================== [McpServerTool(Name = "mrtr-mixed")] @@ -131,7 +131,7 @@ private static async Task MrtrMixed(McpServer server, RequestContext { ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams @@ -144,7 +144,7 @@ private static async Task MrtrMixed(McpServer server, RequestContext { ["name"] = InputRequest.ForElicitation(new ElicitRequestParams @@ -180,7 +180,7 @@ public async Task Mrtr_MixedExceptionAndAwaitStyle(bool experimentalServer, bool // Configure client — experimental or default based on parameter. Action configureClient = experimentalClient - ? options => { ConfigureMrtrHandlers(options); options.ExperimentalProtocolVersion = "2026-06-XX"; } + ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; } : ConfigureMrtrHandlers; if (experimentalServer) @@ -228,7 +228,7 @@ public async Task Mrtr_MixedExceptionAndAwaitStyle(bool experimentalServer, bool } else if (Stateless) { - // Stateless + non-experimental: IncompleteResultException cannot be resolved + // Stateless + non-experimental: InputRequiredException cannot be resolved // (no MRTR and no stateful backcompat). The server returns an error. await using var client = await ConnectAsync(configureClient: configureClient); @@ -242,7 +242,7 @@ public async Task Mrtr_MixedExceptionAndAwaitStyle(bool experimentalServer, bool } else { - // Stateful + non-experimental: backcompat resolves IncompleteResultException + // Stateful + non-experimental: backcompat resolves InputRequiredException // via legacy JSON-RPC requests. The tool completes all 3 rounds. await using var client = await ConnectAsync(configureClient: configureClient); @@ -313,7 +313,7 @@ public async Task Mrtr_ParallelAwaits(bool experimentalServer, bool experimental // Configure client — experimental or default based on parameter. Action configureClient = experimentalClient - ? options => { ConfigureMrtrHandlers(options); options.ExperimentalProtocolVersion = "2026-06-XX"; } + ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; } : ConfigureMrtrHandlers; await using var client = await ConnectAsync(configureClient: configureClient); @@ -354,7 +354,7 @@ private static string MrtrElicit(RequestContext context) return $"elicit-ok:{response.ElicitationResult?.Action}"; } - throw new IncompleteResultException( + throw new InputRequiredException( inputRequests: new Dictionary { ["user_input"] = InputRequest.ForElicitation(new ElicitRequestParams @@ -379,7 +379,7 @@ public async Task Mrtr_LowLevel_Roots_CompletesViaMrtr() return $"roots-ok:{string.Join(",", roots?.Select(r => r.Uri) ?? [])}"; } - throw new IncompleteResultException( + throw new InputRequiredException( inputRequests: new Dictionary { ["roots"] = InputRequest.ForRootsList(new ListRootsRequestParams()) @@ -416,7 +416,7 @@ private static string MrtrMulti(RequestContext context) if (requestState == "round-1" && inputResponses is not null) { var name = inputResponses["name"].ElicitationResult?.Content?.FirstOrDefault().Value; - throw new IncompleteResultException( + throw new InputRequiredException( inputRequests: new Dictionary { ["greeting"] = InputRequest.ForElicitation(new ElicitRequestParams @@ -428,7 +428,7 @@ private static string MrtrMulti(RequestContext context) requestState: "round-2"); } - throw new IncompleteResultException( + throw new InputRequiredException( inputRequests: new Dictionary { ["name"] = InputRequest.ForElicitation(new ElicitRequestParams @@ -452,13 +452,13 @@ public async Task Mrtr_MultiRoundTrip_Completes(bool experimentalClient) // Configure client — experimental or default based on parameter. Action configureClient = experimentalClient - ? options => { ConfigureMrtrHandlers(options); options.ExperimentalProtocolVersion = "2026-06-XX"; } + ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; } : ConfigureMrtrHandlers; await using var client = await ConnectAsync(configureClient: configureClient); if (!experimentalClient && Stateless) { - // Stateless without MRTR: IncompleteResultException can't be resolved + // Stateless without MRTR: InputRequiredException can't be resolved // (no MRTR negotiated and no stateful backcompat path). var ex = await Assert.ThrowsAsync(() => client.CallToolAsync("mrtr-multi", @@ -498,7 +498,7 @@ public async Task Mrtr_IsMrtrSupported(bool experimentalClient) // Configure client — experimental or default based on parameter. Action configureClient = experimentalClient - ? options => { ConfigureMrtrHandlers(options); options.ExperimentalProtocolVersion = "2026-06-XX"; } + ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; } : ConfigureMrtrHandlers; await using var client = await ConnectAsync(configureClient: configureClient); Assert.Equal(experimentalClient ? "2026-06-XX" : "2025-11-25", client.NegotiatedProtocolVersion); @@ -529,7 +529,7 @@ private static string MrtrConcurrentThree(RequestContext return $"all-ok:elicit={elicitAction},sample={sampleText},roots={rootUris}"; } - throw new IncompleteResultException( + throw new InputRequiredException( inputRequests: new Dictionary { ["elicit"] = InputRequest.ForElicitation(new ElicitRequestParams @@ -565,7 +565,7 @@ public async Task Mrtr_ConcurrentThreeInputs_ResolvedSimultaneously() await using var client = await ConnectAsync(configureClient: options => { - options.ExperimentalProtocolVersion = "2026-06-XX"; + options.ProtocolVersion = "DRAFT-2026-v1"; options.Handlers.ElicitationHandler = async (request, ct) => { elicitCalled.TrySetResult(); @@ -614,8 +614,8 @@ public async Task Mrtr_Experimental_LoadShedding_RequestStateOnly_CompletesViaMr return $"resumed:{state}"; } - // requestState-only IncompleteResultException (no inputRequests) - throw new IncompleteResultException(requestState: "deferred-work"); + // requestState-only InputRequiredException (no inputRequests) + throw new InputRequiredException(requestState: "deferred-work"); }); await using var app = Builder.Build(); app.MapMcp(); @@ -646,7 +646,7 @@ public async Task Mrtr_Backcompat_Roots_ResolvedViaLegacyJsonRpc() return $"roots-ok:{roots?.FirstOrDefault()?.Name}"; } - throw new IncompleteResultException( + throw new InputRequiredException( inputRequests: new Dictionary { ["roots"] = InputRequest.ForRootsList(new ListRootsRequestParams()) @@ -684,7 +684,7 @@ public async Task Mrtr_Backcompat_MultipleInputRequests_ResolvedViaLegacyJsonRpc return $"both:{action}:{text}"; } - throw new IncompleteResultException( + throw new InputRequiredException( inputRequests: new Dictionary { ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams @@ -729,7 +729,7 @@ public async Task Mrtr_Backcompat_AlwaysIncomplete_FailsAfterMaxRetries() [McpServerTool(Name = "mrtr-always-incomplete")] (RequestContext context) => { // Always throw — never complete - throw new IncompleteResultException( + throw new InputRequiredException( inputRequests: new Dictionary { ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams @@ -771,7 +771,7 @@ public async Task Mrtr_Backcompat_EmptyInputRequests_FailsWithError() ConfigureExperimentalServer( [McpServerTool(Name = "mrtr-empty-inputs")] (RequestContext context) => { - throw new IncompleteResultException( + throw new InputRequiredException( inputRequests: new Dictionary(), requestState: "empty"); }); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs index f1239e504..e3b74cc62 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs @@ -16,7 +16,7 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// /// Protocol-level tests for Multi Round-Trip Requests (MRTR). /// These tests send raw JSON-RPC requests via HTTP and verify protocol-level behavior -/// including IncompleteResult structure, retry with inputResponses, and error handling. +/// including InputRequiredResult structure, retry with inputResponses, and error handling. /// public class MrtrProtocolTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable { @@ -31,7 +31,7 @@ private async Task StartAsync() Name = nameof(MrtrProtocolTests), Version = "1", }; - options.ExperimentalProtocolVersion = "2026-06-XX"; + options.ProtocolVersion = "DRAFT-2026-v1"; }).WithTools([ McpServerTool.Create( async (string message, McpServer server, CancellationToken ct) => @@ -83,7 +83,7 @@ public async Task ToolThatThrows_ReturnsJsonRpcError_NotIncompleteResult() var response = await PostJsonRpcAsync(CallTool("throwing-tool")); - // Should be a JSON-RPC error, not an IncompleteResult + // Should be a JSON-RPC error, not an InputRequiredResult Assert.Equal(HttpStatusCode.OK, response.StatusCode); var sseData = Assert.Single(await ReadSseAsync(response.Content).ToListAsync(TestContext.Current.CancellationToken)); @@ -116,7 +116,7 @@ public async Task RetryWithInvalidRequestState_ReturnsJsonRpcError() var message = JsonSerializer.Deserialize(sseData, McpJsonUtilities.DefaultOptions); // Invalid requestState should result in a fresh tool invocation - // (the tool will return IncompleteResult since it calls ElicitAsync) + // (the tool will return InputRequiredResult since it calls ElicitAsync) // or an error, depending on the implementation. // In our implementation, unrecognized requestState triggers a new invocation. Assert.True( @@ -134,9 +134,9 @@ public async Task SessionDelete_CancelsPendingMrtrContinuation() var response = await PostJsonRpcAsync(CallTool("elicit-tool", """{"message":"Please confirm"}""")); var rpcResponse = await AssertSingleSseResponseAsync(response); - // Verify we got an IncompleteResult (handler is now suspended, continuation stored). + // Verify we got an InputRequiredResult (handler is now suspended, continuation stored). var resultObj = Assert.IsType(rpcResponse.Result); - Assert.Equal("incomplete", resultObj["result_type"]?.GetValue()); + Assert.Equal("input_required", resultObj["resultType"]?.GetValue()); var requestState = resultObj["requestState"]!.GetValue(); Assert.False(string.IsNullOrEmpty(requestState)); diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientDeferredTaskCreationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientDeferredTaskCreationTests.cs index f21f3603b..911c8ac83 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientDeferredTaskCreationTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientDeferredTaskCreationTests.cs @@ -29,7 +29,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer services.Configure(options => { options.TaskStore = _taskStore; - options.ExperimentalProtocolVersion = "2026-06-XX"; + options.ProtocolVersion = "DRAFT-2026-v1"; }); mcpServerBuilder.WithTools() @@ -171,7 +171,7 @@ private McpClientOptions CreateClientOptions(McpClientHandlers? handlers = null) { return new McpClientOptions { - ExperimentalProtocolVersion = "2026-06-XX", + ProtocolVersion = "DRAFT-2026-v1", TaskStore = _taskStore, Handlers = handlers ?? CreateElicitationHandlers() }; diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs index e611bdc4e..bf69b155a 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs @@ -590,8 +590,8 @@ public async Task ReturnsNegotiatedProtocolVersion(string? protocolVersion) [Fact] public async Task ReturnsNegotiatedProtocolVersion_WithExperimentalProtocol() { - Server.ServerOptions.ExperimentalProtocolVersion = "2026-06-XX"; - await using McpClient client = await CreateMcpClientForServer(new() { ExperimentalProtocolVersion = "2026-06-XX" }); + Server.ServerOptions.ProtocolVersion = "DRAFT-2026-v1"; + await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = "DRAFT-2026-v1" }); Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion); } diff --git a/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs index 100412096..c9b5371cf 100644 --- a/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs @@ -31,7 +31,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug)); services.Configure(options => { - options.ExperimentalProtocolVersion = "2026-06-XX"; + options.ProtocolVersion = "DRAFT-2026-v1"; _messageTracker.AddFilters(options.Filters.Message); }); @@ -79,9 +79,9 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer McpServerTool.Create( (McpServer server) => { - // Low-level MRTR: throw IncompleteResultException directly instead of using ElicitAsync. + // Low-level MRTR: throw InputRequiredException directly instead of using ElicitAsync. // This should NOT be logged at Error level — it's normal MRTR control flow. - throw new IncompleteResultException(new IncompleteResult + throw new InputRequiredException(new InputRequiredResult { InputRequests = new Dictionary { @@ -96,7 +96,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer new McpServerToolCreateOptions { Name = "incomplete-result-tool", - Description = "A tool that throws IncompleteResultException for low-level MRTR" + Description = "A tool that throws InputRequiredException for low-level MRTR" }), McpServerTool.Create( async (McpServer server, RequestContext context, CancellationToken ct) => @@ -104,7 +104,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer var requestState = context.Params!.RequestState; var inputResponses = context.Params!.InputResponses; - // Final round: we have the requestState from the IncompleteResultException + // Final round: we have the requestState from the InputRequiredException if (requestState == "got-name" && inputResponses is not null && inputResponses.TryGetValue("age", out var ageResponse)) { @@ -123,8 +123,8 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer var name = nameResult.Content?.FirstOrDefault().Value; - // Second round: switch to low-level IncompleteResultException (handler dies) - throw new IncompleteResultException( + // Second round: switch to low-level InputRequiredException (handler dies) + throw new InputRequiredException( inputRequests: new Dictionary { ["age"] = InputRequest.ForElicitation(new ElicitRequestParams @@ -138,7 +138,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer new McpServerToolCreateOptions { Name = "elicit-then-incomplete-result-tool", - Description = "A tool that uses high-level ElicitAsync then throws IncompleteResultException" + Description = "A tool that uses high-level ElicitAsync then throws InputRequiredException" }), McpServerTool.Create( async (McpServer server) => @@ -177,7 +177,7 @@ public async Task CallToolAsync_BothExperimental_ElicitCompletesViaMrtr() { // Simplest MRTR success: experimental server + experimental client, one elicitation round. StartServer(); - var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { @@ -209,9 +209,9 @@ public async Task CallToolAsync_ConcurrentElicitAndSample_PropagatesError() // call sees the TCS already completed and throws InvalidOperationException. // That exception is caught by the tool error handler and returned as IsError. StartServer(); - var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; - // The first concurrent call (ElicitAsync) produces an IncompleteResult. + // The first concurrent call (ElicitAsync) produces an InputRequiredResult. // The client resolves it via this handler, which unblocks the first task. // Then Task.WhenAll surfaces the InvalidOperationException from the second task. clientOptions.Handlers.ElicitationHandler = (request, ct) => @@ -242,14 +242,14 @@ public async Task CallToolAsync_ConcurrentElicitAndSample_PropagatesError() public async Task CallToolAsync_ElicitThenIncompleteResultException_WorksEndToEnd() { // Verify that a handler can mix high-level MRTR (ElicitAsync) with low-level MRTR - // (IncompleteResultException) in a single logical flow. The handler: - // 1. Calls ElicitAsync (high-level: handler suspends, IncompleteResult returned) - // 2. Gets the response, then throws IncompleteResultException (low-level: handler dies) + // (InputRequiredException) in a single logical flow. The handler: + // 1. Calls ElicitAsync (high-level: handler suspends, InputRequiredResult returned) + // 2. Gets the response, then throws InputRequiredException (low-level: handler dies) // 3. On the next retry, a fresh handler invocation processes requestState + inputResponses StartServer(); int elicitationCallCount = 0; - var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => { elicitationCallCount++; @@ -265,7 +265,7 @@ public async Task CallToolAsync_ElicitThenIncompleteResultException_WorksEndToEn }); } - // Second elicitation from the IncompleteResultException path + // Second elicitation from the InputRequiredException path return new ValueTask(new ElicitResult { Action = "accept", @@ -287,13 +287,13 @@ public async Task CallToolAsync_ElicitThenIncompleteResultException_WorksEndToEn Assert.Equal("age=30", Assert.IsType(content).Text); Assert.NotEqual(true, result.IsError); - // Two elicitations: one from ElicitAsync, one from IncompleteResultException's inputRequests + // Two elicitations: one from ElicitAsync, one from InputRequiredException's inputRequests Assert.Equal(2, elicitationCallCount); - // Verify no error-level logs for IncompleteResultException + // Verify no error-level logs for InputRequiredException Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Error && - m.Exception is IncompleteResultException); + m.Exception is InputRequiredException); _messageTracker.AssertMrtrUsed(); } @@ -308,7 +308,7 @@ public async Task ClientHandlerException_DuringMrtrInputResolution_SurfacesToCal // input resolution failures back to the server. StartServer(); - var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => { throw new InvalidOperationException("Client-side elicitation failure"); @@ -343,7 +343,7 @@ public async Task SendMessageAsync_WithJsonRpcRequest_ThrowsAlways() // SendMessageAsync should throw InvalidOperationException if the message is a // JsonRpcRequest, regardless of MRTR state. Use SendRequestAsync for requests. StartServer(); - var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); @@ -363,12 +363,12 @@ public async Task LegacyRequestOnMrtrSession_LogsWarning() { // This test simulates a non-compliant server that negotiates MRTR // but sends legacy elicitation/create JSON-RPC requests instead of - // using IncompleteResult. The client should handle it but log a warning. + // using InputRequiredResult. The client should handle it but log a warning. StartServer(); // Required for base class DisposeAsync cleanup var clientToServer = new Pipe(); var serverToClient = new Pipe(); - var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); clientOptions.Handlers.SamplingHandler = (request, progress, ct) => @@ -459,14 +459,14 @@ public async Task LegacyRequestOnMrtrSession_LogsWarning() [Fact] public async Task IncompleteResultOnNonMrtrSession_LogsWarning() { - // This test simulates a non-compliant server that sends an IncompleteResult + // This test simulates a non-compliant server that sends an InputRequiredResult // to a client that did NOT negotiate MRTR. The client should still process it // (resilience), but log a warning about the unexpected protocol behavior. StartServer(); // Required for base class DisposeAsync cleanup var clientToServer = new Pipe(); var serverToClient = new Pipe(); - // Client does NOT set ExperimentalProtocolVersion — standard protocol only + // Client does NOT set DRAFT-2026-v1 — standard protocol only var clientOptions = new McpClientOptions(); clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult @@ -530,10 +530,10 @@ public async Task IncompleteResultOnNonMrtrSession_LogsWarning() Assert.NotNull(callRequest); Assert.Equal("tools/call", callRequest.Method); - // Non-compliant server sends IncompleteResult on standard protocol session! - var incompleteResult = new JsonObject + // Non-compliant server sends InputRequiredResult on standard protocol session! + var InputRequiredResult = new JsonObject { - ["result_type"] = "incomplete", + ["resultType"] = "input_required", ["inputRequests"] = new JsonObject { ["confirm_1"] = JsonSerializer.SerializeToNode( @@ -549,7 +549,7 @@ public async Task IncompleteResultOnNonMrtrSession_LogsWarning() var incompleteResponse = new JsonRpcResponse { Id = callRequest.Id, - Result = incompleteResult, + Result = InputRequiredResult, }; await WriteJsonRpcAsync(serverWriter, incompleteResponse); @@ -578,7 +578,7 @@ public async Task IncompleteResultOnNonMrtrSession_LogsWarning() await WriteJsonRpcAsync(serverWriter, normalResult); }, cancellationToken); - // Client calls the tool — the non-compliant server will send IncompleteResult + // Client calls the tool — the non-compliant server will send InputRequiredResult var response = await client.SendRequestAsync( new JsonRpcRequest { @@ -598,10 +598,10 @@ public async Task IncompleteResultOnNonMrtrSession_LogsWarning() var content = Assert.Single(result.Content); Assert.Equal("completed-without-mrtr", Assert.IsType(content).Text); - // Verify the warning was logged about IncompleteResult on non-MRTR session + // Verify the warning was logged about InputRequiredResult on non-MRTR session Assert.Contains(MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Warning && - m.Message.Contains("IncompleteResult") && + m.Message.Contains("InputRequiredResult") && m.Message.Contains("did not negotiate MRTR")); // Clean up diff --git a/tests/ModelContextProtocol.Tests/Protocol/MrtrSerializationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/MrtrSerializationTests.cs index bb5b6d2d3..447997c33 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/MrtrSerializationTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/MrtrSerializationTests.cs @@ -9,7 +9,7 @@ public static class MrtrSerializationTests [Fact] public static void IncompleteResult_SerializationRoundTrip_PreservesAllProperties() { - var original = new IncompleteResult + var original = new InputRequiredResult { InputRequests = new Dictionary { @@ -28,10 +28,10 @@ public static void IncompleteResult_SerializationRoundTrip_PreservesAllPropertie }; string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); Assert.NotNull(deserialized); - Assert.Equal("incomplete", deserialized.ResultType); + Assert.Equal("input_required", deserialized.ResultType); Assert.Equal("correlation-123", deserialized.RequestState); Assert.NotNull(deserialized.InputRequests); Assert.Equal(2, deserialized.InputRequests.Count); @@ -42,14 +42,14 @@ public static void IncompleteResult_SerializationRoundTrip_PreservesAllPropertie [Fact] public static void IncompleteResult_HasResultTypeIncomplete() { - var result = new IncompleteResult(); - Assert.Equal("incomplete", result.ResultType); + var result = new InputRequiredResult(); + Assert.Equal("input_required", result.ResultType); } [Fact] public static void IncompleteResult_ResultType_AppearsInJson() { - var result = new IncompleteResult + var result = new InputRequiredResult { RequestState = "abc", }; @@ -58,7 +58,7 @@ public static void IncompleteResult_ResultType_AppearsInJson() var node = JsonNode.Parse(json); Assert.NotNull(node); - Assert.Equal("incomplete", (string?)node["result_type"]); + Assert.Equal("input_required", (string?)node["resultType"]); Assert.Equal("abc", (string?)node["requestState"]); } @@ -274,7 +274,7 @@ public static void Result_ResultType_DefaultsToNull() var node = JsonNode.Parse(json); // result_type should not appear for normal results - Assert.Null(node?["result_type"]); + Assert.Null(node?["resultType"]); } [Fact] diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs index 60060127d..0b3db8056 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs @@ -31,7 +31,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug)); services.Configure(options => { - options.ExperimentalProtocolVersion = "2026-06-XX"; + options.ProtocolVersion = "DRAFT-2026-v1"; _messageTracker.AddFilters(options.Filters.Message); }); @@ -161,9 +161,9 @@ await server.ElicitAsync(new ElicitRequestParams McpServerTool.Create( (McpServer server) => { - // Low-level MRTR: throw IncompleteResultException directly instead of using ElicitAsync. + // Low-level MRTR: throw InputRequiredException directly instead of using ElicitAsync. // This should NOT be logged at Error level — it's normal MRTR control flow. - throw new IncompleteResultException(new IncompleteResult + throw new InputRequiredException(new InputRequiredResult { InputRequests = new Dictionary { @@ -178,7 +178,7 @@ await server.ElicitAsync(new ElicitRequestParams new McpServerToolCreateOptions { Name = "incomplete-result-tool", - Description = "A tool that throws IncompleteResultException for low-level MRTR" + Description = "A tool that throws InputRequiredException for low-level MRTR" }) ]); } @@ -191,7 +191,7 @@ public async Task CallToolAsync_CancellationDuringMrtrRetry_ThrowsOperationCance StartServer(); var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => { // Cancel the token during the callback. The retry loop will throw @@ -219,7 +219,7 @@ public async Task ServerDisposal_CancelsHandlerCancellationToken_DuringMrtr() StartServer(); var elicitHandlerCalled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; clientOptions.Handlers.ElicitationHandler = async (request, ct) => { // Signal that the MRTR round trip reached the client, then block indefinitely. @@ -263,7 +263,7 @@ public async Task CancellationNotification_DuringInFlightMrtrRetry_CancelsHandle // (c) the cancellation registration in AwaitMrtrHandlerAsync bridges to handlerCts. StartServer(); - var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); @@ -296,12 +296,12 @@ public async Task CancellationNotification_ForExpiredRequestId_DoesNotAffectHand { // Verify that a stale cancellation notification for the original (now-completed) // request ID does not interfere with an active MRTR handler. The original request's - // entry was removed from _handlingRequests when it returned IncompleteResult, so + // entry was removed from _handlingRequests when it returned InputRequiredResult, so // the notification should be a no-op. StartServer(); int elicitationCount = 0; - var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => { Interlocked.Increment(ref elicitationCount); @@ -345,7 +345,7 @@ public async Task DisposeAsync_WaitsForMrtrHandler_BeforeReturning() StartServer(); bool handlerCompleted = false; - var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); @@ -387,7 +387,7 @@ public async Task HandlerException_DuringMrtr_IsLoggedAtErrorLevel() // (after resuming from ElicitAsync), the error is logged at Error level. StartServer(); - var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); @@ -413,17 +413,17 @@ public async Task HandlerException_DuringMrtr_IsLoggedAtErrorLevel() [Fact] public async Task IncompleteResultException_IsNotLoggedAtErrorLevel() { - // IncompleteResultException is normal MRTR control flow (low-level API), + // InputRequiredException is normal MRTR control flow (low-level API), // not an error. It should not be logged via ToolCallError at Error level. StartServer(); - var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); await using var client = await CreateMcpClientForServer(clientOptions); - // The tool always throws IncompleteResultException (low-level MRTR path), + // The tool always throws InputRequiredException (low-level MRTR path), // so the client will retry until hitting the max retry limit. await Assert.ThrowsAsync(() => client.CallToolAsync( "incomplete-result-tool", @@ -431,7 +431,7 @@ await Assert.ThrowsAsync(() => client.CallToolAsync( Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Error && - m.Exception is IncompleteResultException); + m.Exception is InputRequiredException); _messageTracker.AssertMrtrUsed(); } diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrLowLevelApiTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrLowLevelApiTests.cs index bcf76bb95..4b038965a 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrLowLevelApiTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrLowLevelApiTests.cs @@ -7,7 +7,7 @@ namespace ModelContextProtocol.Tests.Server; /// -/// Tests for the low-level MRTR server API — IsMrtrSupported, IncompleteResultException, +/// Tests for the low-level MRTR server API — IsMrtrSupported, InputRequiredException, /// and client auto-retry of incomplete results. /// public class MrtrLowLevelApiTests : ClientServerTestBase @@ -23,7 +23,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer { services.Configure(options => { - options.ExperimentalProtocolVersion = "2026-06-XX"; + options.ProtocolVersion = "DRAFT-2026-v1"; _messageTracker.AddFilters(options.Filters.Message); }); @@ -31,12 +31,12 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer McpServerTool.Create( static string (McpServer server) => { - throw new IncompleteResultException(requestState: "should-not-work"); + throw new InputRequiredException(requestState: "should-not-work"); }, new McpServerToolCreateOptions { Name = "always-incomplete", - Description = "Tool that always throws IncompleteResultException" + Description = "Tool that always throws InputRequiredException" }), ]); } @@ -45,12 +45,12 @@ static string (McpServer server) => public async Task LowLevel_IncompleteResultException_WithoutExperimental_ReturnsError() { StartServer(); - // Client does NOT set ExperimentalProtocolVersion + // Client does NOT set DRAFT-2026-v1 var clientOptions = new McpClientOptions(); await using var client = await CreateMcpClientForServer(clientOptions); - // The always-incomplete tool throws IncompleteResultException with only requestState + // The always-incomplete tool throws InputRequiredException with only requestState // and no inputRequests. Without MRTR negotiated, the backcompat layer can't resolve // the request (no inputRequests to dispatch), so it wraps it in an error. var exception = await Assert.ThrowsAsync(() => diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs index 54e52281a..a86223962 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs @@ -10,7 +10,7 @@ namespace ModelContextProtocol.Tests.Server; /// /// Tests that message filters correctly observe MRTR protocol behavior — verifying that -/// IncompleteResult responses are visible to outgoing filters, and that no legacy +/// InputRequiredResult responses are visible to outgoing filters, and that no legacy /// elicitation/sampling requests are sent when MRTR is active. /// public class MrtrMessageFilterTests : ClientServerTestBase @@ -26,7 +26,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer { services.Configure(options => { - options.ExperimentalProtocolVersion = "2026-06-XX"; + options.ProtocolVersion = "DRAFT-2026-v1"; _messageTracker.AddFilters(options.Filters.Message); }); @@ -71,9 +71,9 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer public async Task MrtrActive_NoOldStyleElicitationRequests_SentOverWire() { // When both sides are on the experimental protocol, the server should use MRTR - // (IncompleteResult) instead of sending old-style elicitation/create JSON-RPC requests. + // (InputRequiredResult) instead of sending old-style elicitation/create JSON-RPC requests. StartServer(); - var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => { return new ValueTask(new ElicitResult { Action = "accept" }); @@ -95,7 +95,7 @@ public async Task MrtrActive_NoOldStyleElicitationRequests_SentOverWire() public async Task MrtrActive_NoOldStyleSamplingRequests_SentOverWire() { StartServer(); - var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; clientOptions.Handlers.SamplingHandler = (request, progress, ct) => { var text = request?.Messages[^1].Content.OfType().FirstOrDefault()?.Text; @@ -121,15 +121,15 @@ public async Task MrtrActive_NoOldStyleSamplingRequests_SentOverWire() [Fact] public async Task OutgoingFilter_SeesIncompleteResultResponse() { - // Verify that transport middleware can observe the raw IncompleteResult + // Verify that transport middleware can observe the raw InputRequiredResult // in outgoing JSON-RPC responses (validates MRTR transport visibility). var sawIncompleteResult = false; StartServer(); - var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => { - // If we reach this handler, it means the client received an IncompleteResult + // If we reach this handler, it means the client received an InputRequiredResult // from the server, resolved the elicitation, and is retrying. sawIncompleteResult = true; return new ValueTask(new ElicitResult { Action = "accept" }); @@ -142,8 +142,8 @@ await client.CallToolAsync("elicit-tool", cancellationToken: TestContext.Current.CancellationToken); // The elicitation handler was called, confirming MRTR round-trip occurred - // (IncompleteResult was sent by server and processed by client). - Assert.True(sawIncompleteResult, "Expected MRTR round-trip with IncompleteResult"); + // (InputRequiredResult was sent by server and processed by client). + Assert.True(sawIncompleteResult, "Expected MRTR round-trip with InputRequiredResult"); _messageTracker.AssertMrtrUsed(); } } diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs index 24d7d96fc..b4e51ff6e 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs @@ -16,7 +16,7 @@ namespace ModelContextProtocol.Tests.Server; public class MrtrSessionLimitTests : ClientServerTestBase { /// - /// Tracks the number of pending MRTR flows per session. Incremented when an IncompleteResult + /// Tracks the number of pending MRTR flows per session. Incremented when an InputRequiredResult /// is sent (outgoing filter), decremented when a retry with requestState arrives (incoming filter). /// private readonly ConcurrentDictionary _pendingFlowsPerSession = new(); @@ -31,7 +31,7 @@ public class MrtrSessionLimitTests : ClientServerTestBase /// /// Maximum allowed concurrent MRTR flows per session. If exceeded, the outgoing filter - /// replaces the IncompleteResult with an error response. + /// replaces the InputRequiredResult with an error response. /// private int _maxFlowsPerSession = int.MaxValue; @@ -49,24 +49,24 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer { services.Configure(options => { - options.ExperimentalProtocolVersion = "2026-06-XX"; + options.ProtocolVersion = "DRAFT-2026-v1"; _messageTracker.AddFilters(options.Filters.Message); - // Outgoing filter: detect IncompleteResult responses and track per session. + // Outgoing filter: detect InputRequiredResult responses and track per session. options.Filters.Message.OutgoingFilters.Add(next => async (context, cancellationToken) => { if (context.JsonRpcMessage is JsonRpcResponse response && response.Result is JsonObject resultObj && - resultObj.TryGetPropertyValue("result_type", out var resultTypeNode) && - resultTypeNode?.GetValue() is "incomplete") + resultObj.TryGetPropertyValue("resultType", out var resultTypeNode) && + resultTypeNode?.GetValue() is "input_required") { var sessionId = context.Server.SessionId ?? "unknown"; var newCount = _pendingFlowsPerSession.AddOrUpdate(sessionId, 1, (_, c) => c + 1); _observations.Add((sessionId, newCount)); - // Enforce per-session limit: if exceeded, replace the IncompleteResult + // Enforce per-session limit: if exceeded, replace the InputRequiredResult // with a JSON-RPC error. This prevents the client from receiving the - // IncompleteResult and starting another retry cycle. + // InputRequiredResult and starting another retry cycle. if (newCount > _maxFlowsPerSession) { // Undo the increment since we're blocking this flow. @@ -128,10 +128,10 @@ request.Params is JsonObject paramsObj && [Fact] public async Task OutgoingFilter_TracksIncompleteResultsPerSession() { - // Verify that an outgoing message filter can observe IncompleteResult responses + // Verify that an outgoing message filter can observe InputRequiredResult responses // and track the pending MRTR flow count per session using context.Server.SessionId. StartServer(); - var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); @@ -144,7 +144,7 @@ public async Task OutgoingFilter_TracksIncompleteResultsPerSession() Assert.Equal("accept", Assert.IsType(Assert.Single(result.Content)).Text); - // Verify the filter observed exactly one IncompleteResult and tracked it. + // Verify the filter observed exactly one InputRequiredResult and tracked it. Assert.Single(_observations); var (sessionId, pendingCount) = _observations.First(); Assert.NotNull(sessionId); @@ -160,18 +160,18 @@ public async Task OutgoingFilter_TracksIncompleteResultsPerSession() public async Task OutgoingFilter_CanEnforcePerSessionMrtrLimit() { // Verify that an outgoing message filter can enforce a per-session MRTR flow limit - // by replacing the IncompleteResult with a JSON-RPC error when the limit is exceeded. + // by replacing the InputRequiredResult with a JSON-RPC error when the limit is exceeded. // Set the limit to 0 so the very first MRTR flow is blocked. _maxFlowsPerSession = 0; StartServer(); - var clientOptions = new McpClientOptions { ExperimentalProtocolVersion = "2026-06-XX" }; + var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; clientOptions.Handlers.ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult { Action = "accept" }); await using var client = await CreateMcpClientForServer(clientOptions); - // The tool call should fail because the outgoing filter blocks the IncompleteResult. + // The tool call should fail because the outgoing filter blocks the InputRequiredResult. var ex = await Assert.ThrowsAsync(async () => await client.CallToolAsync("elicit-tool", new Dictionary { ["message"] = "confirm?" }, diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrTaskIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrTaskIntegrationTests.cs index 7c32b54a9..9d1c171b5 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrTaskIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrTaskIntegrationTests.cs @@ -26,7 +26,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer services.Configure(options => { options.TaskStore = taskStore; - options.ExperimentalProtocolVersion = "2026-06-XX"; + options.ProtocolVersion = "DRAFT-2026-v1"; }); mcpServerBuilder.WithTools([ @@ -78,7 +78,7 @@ public async Task TaskAugmentedToolCall_WithMrtrSampling_TracksInputRequiredStat var clientOptions = new McpClientOptions { - ExperimentalProtocolVersion = "2026-06-XX", + ProtocolVersion = "DRAFT-2026-v1", TaskStore = taskStore, Handlers = new McpClientHandlers { @@ -143,7 +143,7 @@ public async Task TaskAugmentedToolCall_WithMrtrElicitation_CompletesSuccessfull StartServer(); var clientOptions = new McpClientOptions { - ExperimentalProtocolVersion = "2026-06-XX", + ProtocolVersion = "DRAFT-2026-v1", Handlers = new McpClientHandlers { ElicitationHandler = (request, ct) => @@ -175,7 +175,7 @@ public async Task SampleAsTaskAsync_BypassesMrtrInterception() var clientOptions = new McpClientOptions { - ExperimentalProtocolVersion = "2026-06-XX", + ProtocolVersion = "DRAFT-2026-v1", TaskStore = taskStore, Handlers = new McpClientHandlers { @@ -236,7 +236,7 @@ public async Task MrtrToolCall_ThenTaskBasedSampling_BothWorkCorrectly() var clientOptions = new McpClientOptions { - ExperimentalProtocolVersion = "2026-06-XX", + ProtocolVersion = "DRAFT-2026-v1", TaskStore = taskStore, Handlers = new McpClientHandlers { From 8205a0ff9f695805eba1fe0e6885fd6429da6375 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 19 May 2026 07:54:20 -0700 Subject: [PATCH 03/14] Refine MRTR gating and align tests with new protocol model - 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> --- .../Server/McpServerImpl.cs | 25 +++++++++++-------- .../MapMcpTests.Mrtr.cs | 17 +++++++------ .../MrtrProtocolTests.cs | 6 ++--- .../Client/McpClientTests.cs | 2 +- .../Client/MrtrIntegrationTests.cs | 8 +++--- .../Server/MrtrLowLevelApiTests.cs | 11 ++++---- .../Server/MrtrMessageFilterTests.cs | 4 +-- 7 files changed, 38 insertions(+), 35 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 2eb62cd61..e9653c3eb 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -1203,14 +1203,14 @@ internal bool ClientSupportsMrtr() => _negotiatedProtocolVersion == McpSessionHandler.DraftProtocolVersion; /// - /// Returns when the session has negotiated a pre-DRAFT-2026-v1 protocol - /// version on a stateful transport (i.e., a transport that supports Mcp-Session-Id). These sessions - /// keep the implicit MRTR behavior where a handler can call ElicitAsync/SampleAsync - /// and the SDK suspends/resumes the handler across an round trip. + /// Returns when the session is stateful (i.e., the same server instance + /// will handle subsequent requests). The implicit MRTR path — where a handler can call + /// ElicitAsync/SampleAsync and the SDK suspends/resumes the handler across an + /// round trip — requires the continuation map to outlive the + /// initial response, so it is only available on stateful sessions. Stateless transports always + /// go through the exception-based path. /// - internal bool IsLegacyStatefulSession() => - _negotiatedProtocolVersion is not null && - _negotiatedProtocolVersion != McpSessionHandler.DraftProtocolVersion && + internal bool IsStatefulSession() => _sessionTransport is not StreamableHttpServerTransport { Stateless: true }; /// @@ -1309,10 +1309,13 @@ private void WrapHandlerWithMrtr(string method) // high-level handlers that call ElicitAsync/SampleAsync. } - // Implicit MRTR (handler suspension across ElicitAsync/SampleAsync) is reserved for - // legacy stateful sessions only. DRAFT-2026-v1 sessions always go through the exception - // path (the client drives the retry loop). Stateless sessions also use the exception path. - if (!IsLegacyStatefulSession()) + // Implicit MRTR (handler suspension across ElicitAsync/SampleAsync) emits + // InputRequiredResult on the wire, which only DRAFT-2026-v1 clients understand, + // and requires the same server instance to handle the retry (stateful session). + // For all other cases — legacy clients, stateless sessions — fall through to the + // exception-based path, which transparently resolves InputRequiredException via + // legacy JSON-RPC requests when the client doesn't speak MRTR. + if (!ClientSupportsMrtr() || !IsStatefulSession()) { return await InvokeWithInputRequiredResultHandlingAsync(originalHandler, request, cancellationToken).ConfigureAwait(false); } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs index ea6ded62f..dee7ce37a 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs @@ -16,7 +16,8 @@ private ServerMessageTracker ConfigureExperimentalServer(params Delegate[] tools Builder.Services.AddMcpServer(options => { options.ServerInfo = new Implementation { Name = "ExperimentalServer", Version = "1" }; - options.ProtocolVersion = "DRAFT-2026-v1"; + // Don't pin a protocol version here — let it be negotiated based on what the client + // requests. DRAFT-2026-v1 is in SupportedProtocolVersions, so an opt-in client gets it. messageTracker.AddFilters(options.Filters.Message); }) .WithHttpTransport(ConfigureStateless) @@ -194,7 +195,7 @@ public async Task Mrtr_MixedExceptionAndAwaitStyle(bool experimentalServer, bool if (experimentalClient) { // Both experimental — MRTR end-to-end. - Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion); + Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); } else { @@ -322,7 +323,7 @@ public async Task Mrtr_ParallelAwaits(bool experimentalServer, bool experimental // Both experimental — MRTR active. Parallel awaits hit the MrtrContext // concurrency gate and the second call throws InvalidOperationException, // which the tool catches and returns as text. - Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion); + Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-parallel-await", cancellationToken: TestContext.Current.CancellationToken); @@ -390,7 +391,7 @@ public async Task Mrtr_LowLevel_Roots_CompletesViaMrtr() app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); await using var client = await ConnectExperimentalAsync(); - Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion); + Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-roots", cancellationToken: TestContext.Current.CancellationToken); @@ -476,7 +477,7 @@ public async Task Mrtr_MultiRoundTrip_Completes(bool experimentalClient) if (experimentalClient) { - Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion); + Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); messageTracker.AssertMrtrUsed(); } else @@ -501,7 +502,7 @@ public async Task Mrtr_IsMrtrSupported(bool experimentalClient) ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; } : ConfigureMrtrHandlers; await using var client = await ConnectAsync(configureClient: configureClient); - Assert.Equal(experimentalClient ? "2026-06-XX" : "2025-11-25", client.NegotiatedProtocolVersion); + Assert.Equal(experimentalClient ? "DRAFT-2026-v1" : "2025-11-25", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-check", cancellationToken: TestContext.Current.CancellationToken); @@ -592,7 +593,7 @@ public async Task Mrtr_ConcurrentThreeInputs_ResolvedSimultaneously() }; }; }); - Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion); + Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-concurrent-three", cancellationToken: TestContext.Current.CancellationToken); @@ -621,7 +622,7 @@ public async Task Mrtr_Experimental_LoadShedding_RequestStateOnly_CompletesViaMr app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); await using var client = await ConnectExperimentalAsync(); - Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion); + Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-loadshed", cancellationToken: TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs index e3b74cc62..da6357990 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs @@ -264,7 +264,7 @@ private string CallTool(string toolName, string arguments = "{}") => private async Task InitializeWithMrtrAsync() { var initJson = """ - {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-06-XX","capabilities":{"sampling":{},"elicitation":{},"roots":{}},"clientInfo":{"name":"MrtrTestClient","version":"1.0.0"}}} + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"DRAFT-2026-v1","capabilities":{"sampling":{},"elicitation":{},"roots":{}},"clientInfo":{"name":"MrtrTestClient","version":"1.0.0"}}} """; using var response = await PostJsonRpcAsync(initJson); @@ -273,7 +273,7 @@ private async Task InitializeWithMrtrAsync() // Verify the server negotiated to the experimental version var protocolVersion = rpcResponse.Result["protocolVersion"]?.GetValue(); - Assert.Equal("2026-06-XX", protocolVersion); + Assert.Equal("DRAFT-2026-v1", protocolVersion); var sessionId = Assert.Single(response.Headers.GetValues("mcp-session-id")); HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); @@ -281,7 +281,7 @@ private async Task InitializeWithMrtrAsync() // Set the MCP-Protocol-Version header for subsequent requests HttpClient.DefaultRequestHeaders.Remove("MCP-Protocol-Version"); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "2026-06-XX"); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "DRAFT-2026-v1"); // Reset request ID counter since initialize used ID 1 _lastRequestId = 1; diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs index bf69b155a..749ef51eb 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs @@ -592,7 +592,7 @@ public async Task ReturnsNegotiatedProtocolVersion_WithExperimentalProtocol() { Server.ServerOptions.ProtocolVersion = "DRAFT-2026-v1"; await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = "DRAFT-2026-v1" }); - Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion); + Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); } [Fact] diff --git a/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs index c9b5371cf..7ac972040 100644 --- a/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs @@ -189,7 +189,7 @@ public async Task CallToolAsync_BothExperimental_ElicitCompletesViaMrtr() }); await using var client = await CreateMcpClientForServer(clientOptions); - Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion); + Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("elicitation-tool", new Dictionary { ["message"] = "What is your name?" }, @@ -315,7 +315,7 @@ public async Task ClientHandlerException_DuringMrtrInputResolution_SurfacesToCal }; await using var client = await CreateMcpClientForServer(clientOptions); - Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion); + Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); // The client handler throws during input resolution, so the exception // escapes ResolveInputRequestAsync and surfaces directly to the caller. @@ -405,7 +405,7 @@ public async Task LegacyRequestOnMrtrSession_LogsWarning() Id = initRequest.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "2026-06-XX", + ProtocolVersion = "DRAFT-2026-v1", Capabilities = new ServerCapabilities(), ServerInfo = new Implementation { Name = "MockMrtrServer", Version = "1.0" } }, McpJsonUtilities.DefaultOptions), @@ -418,7 +418,7 @@ public async Task LegacyRequestOnMrtrSession_LogsWarning() // Client is now connected with MRTR negotiated await using var client = await clientTask; - Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion); + Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); // Now simulate the non-compliant server sending a legacy elicitation/create request var legacyRequest = new JsonRpcRequest diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrLowLevelApiTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrLowLevelApiTests.cs index 4b038965a..f95439e32 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrLowLevelApiTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrLowLevelApiTests.cs @@ -42,21 +42,20 @@ static string (McpServer server) => } [Fact] - public async Task LowLevel_IncompleteResultException_WithoutExperimental_ReturnsError() + public async Task LowLevel_InputRequiredException_WithoutInputRequests_ExhaustsRetries() { StartServer(); - // Client does NOT set DRAFT-2026-v1 var clientOptions = new McpClientOptions(); await using var client = await CreateMcpClientForServer(clientOptions); // The always-incomplete tool throws InputRequiredException with only requestState - // and no inputRequests. Without MRTR negotiated, the backcompat layer can't resolve - // the request (no inputRequests to dispatch), so it wraps it in an error. - var exception = await Assert.ThrowsAsync(() => + // and no inputRequests. The client has nothing to dispatch, so it keeps retrying + // with the same requestState until the retry budget is exhausted. + var exception = await Assert.ThrowsAsync(() => client.CallToolAsync("always-incomplete", cancellationToken: TestContext.Current.CancellationToken).AsTask()); - Assert.Contains("without input requests", exception.Message); + Assert.Contains("more than", exception.Message); } } diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs index a86223962..309086db4 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs @@ -80,7 +80,7 @@ public async Task MrtrActive_NoOldStyleElicitationRequests_SentOverWire() }; await using var client = await CreateMcpClientForServer(clientOptions); - Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion); + Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("elicit-tool", new Dictionary { ["message"] = "test" }, @@ -107,7 +107,7 @@ public async Task MrtrActive_NoOldStyleSamplingRequests_SentOverWire() }; await using var client = await CreateMcpClientForServer(clientOptions); - Assert.Equal("2026-06-XX", client.NegotiatedProtocolVersion); + Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("sample-tool", new Dictionary { ["prompt"] = "test" }, From 302041227364d9d688cd6c16c0e5fe587a94fdc9 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 19 May 2026 12:59:46 -0700 Subject: [PATCH 04/14] Collapse AspNetCore MRTR test matrix to single ProtocolVersion axis Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MapMcpTests.Mrtr.cs | 177 +++++++----------- .../MrtrProtocolTests.cs | 33 +++- 2 files changed, 98 insertions(+), 112 deletions(-) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs index dee7ce37a..29e4094cc 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs @@ -10,27 +10,15 @@ namespace ModelContextProtocol.AspNetCore.Tests; public abstract partial class MapMcpTests { - private ServerMessageTracker ConfigureExperimentalServer(params Delegate[] tools) + private ServerMessageTracker ConfigureServer(params Delegate[] tools) { var messageTracker = new ServerMessageTracker(); Builder.Services.AddMcpServer(options => { - options.ServerInfo = new Implementation { Name = "ExperimentalServer", Version = "1" }; - // Don't pin a protocol version here — let it be negotiated based on what the client - // requests. DRAFT-2026-v1 is in SupportedProtocolVersions, so an opt-in client gets it. - messageTracker.AddFilters(options.Filters.Message); - }) - .WithHttpTransport(ConfigureStateless) - .WithTools(tools.Select(t => McpServerTool.Create(t))); - return messageTracker; - } - - private ServerMessageTracker ConfigureDefaultServer(params Delegate[] tools) - { - var messageTracker = new ServerMessageTracker(); - Builder.Services.AddMcpServer(options => - { - options.ServerInfo = new Implementation { Name = "DefaultServer", Version = "1" }; + options.ServerInfo = new Implementation { Name = "MrtrTestServer", Version = "1" }; + // Do not pin a protocol version — let it be negotiated based on what the client requests. + // DRAFT-2026-v1 is in SupportedProtocolVersions, so an opt-in client gets it; others get + // the latest non-draft. messageTracker.AddFilters(options.Filters.Message); }) .WithHttpTransport(ConfigureStateless) @@ -164,75 +152,34 @@ private static async Task MrtrMixed(McpServer server, RequestContext configureClient = experimentalClient ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; } : ConfigureMrtrHandlers; - if (experimentalServer) - { - // Success cases: both exception and await APIs complete. - // Skip stateless — await API requires handler suspension (stateful only). - Assert.SkipWhen(Stateless, "Await-style API requires handler suspension (stateful only)."); - - await using var client = await ConnectAsync(configureClient: configureClient); - - if (experimentalClient) - { - // Both experimental — MRTR end-to-end. - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); - } - else - { - // Backcompat — server experimental, client default. Legacy JSON-RPC. - Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); - } - - var result = await client.CallToolAsync("mrtr-mixed", - cancellationToken: TestContext.Current.CancellationToken); - - var text = Assert.IsType(Assert.Single(result.Content)).Text; - Assert.True(result.IsError is not true); - var parts = text.Split('|'); - Assert.Equal(3, parts.Length); - - // confirmation from round 2 elicitation - Assert.Equal("accept", parts[0]); - // greeting from await SampleAsync — our test handler returns "LLM:{prompt}" - Assert.StartsWith("LLM:", parts[1]); - // signoff from await ElicitAsync - Assert.Equal("accept", parts[2]); - - if (experimentalClient) - { - messageTracker.AssertMrtrUsed(); - } - else - { - messageTracker.AssertMrtrNotUsed(); - } - } - else if (Stateless) + // The await-style portion of this tool calls server.SampleAsync/ElicitAsync on round 3. + // In stateless mode, those calls succeed only when the request is still open on the same + // SSE stream — which it is — so the tool runs end-to-end as long as the input requests + // themselves can be resolved (MRTR client) or replayed via legacy JSON-RPC (stateful + legacy). + if (Stateless && !experimentalClient) { - // Stateless + non-experimental: InputRequiredException cannot be resolved - // (no MRTR and no stateful backcompat). The server returns an error. + // Stateless + legacy client: InputRequiredException cannot be resolved (no MRTR wire + // and no persistent server instance for the backcompat retry loop). The server returns + // a JSON-RPC error. await using var client = await ConnectAsync(configureClient: configureClient); - var ex = await Assert.ThrowsAsync(() => client.CallToolAsync("mrtr-mixed", cancellationToken: TestContext.Current.CancellationToken).AsTask()); @@ -240,25 +187,41 @@ public async Task Mrtr_MixedExceptionAndAwaitStyle(bool experimentalServer, bool Assert.Equal(McpErrorCode.InternalError, ex.ErrorCode); Assert.Contains("stateless", ex.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("MRTR", ex.Message); + return; } - else + + if (Stateless && experimentalClient) { - // Stateful + non-experimental: backcompat resolves InputRequiredException - // via legacy JSON-RPC requests. The tool completes all 3 rounds. - await using var client = await ConnectAsync(configureClient: configureClient); + // Stateless + MRTR client: the await-style portion (server.SampleAsync on round 3) + // requires handler suspension across requests, which only works in stateful mode. + // Skip this combination — the await API is documented as stateful-only. + Assert.SkipWhen(true, "Await-style API requires handler suspension (stateful only)."); + return; + } - var result = await client.CallToolAsync("mrtr-mixed", - cancellationToken: TestContext.Current.CancellationToken); + // Stateful path — both client modes complete all 3 rounds. + await using var statefulClient = await ConnectAsync(configureClient: configureClient); - var text = Assert.IsType(Assert.Single(result.Content)).Text; - Assert.True(result.IsError is not true); - var parts = text.Split('|'); - Assert.Equal(3, parts.Length); + Assert.Equal(experimentalClient ? "DRAFT-2026-v1" : "2025-11-25", + statefulClient.NegotiatedProtocolVersion); - Assert.Equal("accept", parts[0]); - Assert.StartsWith("LLM:", parts[1]); - Assert.Equal("accept", parts[2]); + var result = await statefulClient.CallToolAsync("mrtr-mixed", + cancellationToken: TestContext.Current.CancellationToken); + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.True(result.IsError is not true); + var parts = text.Split('|'); + Assert.Equal(3, parts.Length); + Assert.Equal("accept", parts[0]); + Assert.StartsWith("LLM:", parts[1]); + Assert.Equal("accept", parts[2]); + + if (experimentalClient) + { + messageTracker.AssertMrtrUsed(); + } + else + { messageTracker.AssertMrtrNotUsed(); } } @@ -295,34 +258,28 @@ private static async Task MrtrParallelAwait(McpServer server, Cancellati } [Theory] - [InlineData(true, true)] - [InlineData(true, false)] - [InlineData(false, true)] - [InlineData(false, false)] - public async Task Mrtr_ParallelAwaits(bool experimentalServer, bool experimentalClient) + [InlineData(true)] + [InlineData(false)] + public async Task Mrtr_ParallelAwaits(bool experimentalClient) { // Parallel awaits work with regular JSON-RPC but fail with MRTR because // MrtrContext only supports one exchange at a time (TrySetResult gate). Assert.SkipWhen(Stateless, "Await-style API requires handler suspension (stateful only)."); - var messageTracker = experimentalServer - ? ConfigureExperimentalServer(MrtrParallelAwait) - : ConfigureDefaultServer(MrtrParallelAwait); + var messageTracker = ConfigureServer(MrtrParallelAwait); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); - // Configure client — experimental or default based on parameter. Action configureClient = experimentalClient ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; } : ConfigureMrtrHandlers; await using var client = await ConnectAsync(configureClient: configureClient); - if (experimentalServer && experimentalClient) + if (experimentalClient) { - // Both experimental — MRTR active. Parallel awaits hit the MrtrContext - // concurrency gate and the second call throws InvalidOperationException, - // which the tool catches and returns as text. + // MRTR active. Parallel awaits hit the MrtrContext concurrency gate and the second + // call throws InvalidOperationException, which the tool catches and returns as text. Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-parallel-await", @@ -370,7 +327,7 @@ private static string MrtrElicit(RequestContext context) [Fact] public async Task Mrtr_LowLevel_Roots_CompletesViaMrtr() { - var messageTracker = ConfigureExperimentalServer( + var messageTracker = ConfigureServer( [McpServerTool(Name = "mrtr-roots")] (RequestContext context) => { if (context.Params!.InputResponses is { } responses && @@ -446,7 +403,7 @@ private static string MrtrMulti(RequestContext context) [InlineData(false)] public async Task Mrtr_MultiRoundTrip_Completes(bool experimentalClient) { - var messageTracker = ConfigureExperimentalServer(MrtrMulti); + var messageTracker = ConfigureServer(MrtrMulti); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); @@ -492,7 +449,7 @@ public async Task Mrtr_MultiRoundTrip_Completes(bool experimentalClient) [InlineData(false)] public async Task Mrtr_IsMrtrSupported(bool experimentalClient) { - ConfigureExperimentalServer([McpServerTool(Name = "mrtr-check")] (McpServer server) => server.IsMrtrSupported.ToString()); + ConfigureServer([McpServerTool(Name = "mrtr-check")] (McpServer server) => server.IsMrtrSupported.ToString()); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); @@ -555,7 +512,7 @@ private static string MrtrConcurrentThree(RequestContext [Fact] public async Task Mrtr_ConcurrentThreeInputs_ResolvedSimultaneously() { - var messageTracker = ConfigureExperimentalServer(MrtrConcurrentThree); + var messageTracker = ConfigureServer(MrtrConcurrentThree); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); @@ -607,7 +564,7 @@ public async Task Mrtr_ConcurrentThreeInputs_ResolvedSimultaneously() [Fact] public async Task Mrtr_Experimental_LoadShedding_RequestStateOnly_CompletesViaMrtr() { - var messageTracker = ConfigureExperimentalServer( + var messageTracker = ConfigureServer( [McpServerTool(Name = "mrtr-loadshed")] (RequestContext context) => { if (context.Params!.RequestState is { } state) @@ -637,7 +594,7 @@ public async Task Mrtr_Experimental_LoadShedding_RequestStateOnly_CompletesViaMr public async Task Mrtr_Backcompat_Roots_ResolvedViaLegacyJsonRpc() { Assert.SkipWhen(Stateless, "Backcompat requires stateful server for legacy JSON-RPC."); - var messageTracker = ConfigureExperimentalServer( + var messageTracker = ConfigureServer( [McpServerTool(Name = "mrtr-roots-backcompat")] (RequestContext context) => { if (context.Params!.InputResponses is { } responses && @@ -673,7 +630,7 @@ public async Task Mrtr_Backcompat_Roots_ResolvedViaLegacyJsonRpc() public async Task Mrtr_Backcompat_MultipleInputRequests_ResolvedViaLegacyJsonRpc() { Assert.SkipWhen(Stateless, "Backcompat requires stateful server for legacy JSON-RPC."); - var messageTracker = ConfigureExperimentalServer( + var messageTracker = ConfigureServer( [McpServerTool(Name = "mrtr-multi-input")] (RequestContext context) => { if (context.Params!.InputResponses is { } responses && @@ -726,7 +683,7 @@ public async Task Mrtr_Backcompat_AlwaysIncomplete_FailsAfterMaxRetries() Assert.SkipWhen(Stateless, "Backcompat requires stateful server for legacy JSON-RPC."); int elicitCallCount = 0; - ConfigureExperimentalServer( + ConfigureServer( [McpServerTool(Name = "mrtr-always-incomplete")] (RequestContext context) => { // Always throw — never complete @@ -769,7 +726,7 @@ public async Task Mrtr_Backcompat_AlwaysIncomplete_FailsAfterMaxRetries() public async Task Mrtr_Backcompat_EmptyInputRequests_FailsWithError() { Assert.SkipWhen(Stateless, "Backcompat requires stateful server for legacy JSON-RPC."); - ConfigureExperimentalServer( + ConfigureServer( [McpServerTool(Name = "mrtr-empty-inputs")] (RequestContext context) => { throw new InputRequiredException( @@ -795,7 +752,7 @@ public async Task Mrtr_Backcompat_ClientHandlerThrows_PropagatesError() { Assert.SkipWhen(Stateless, "Backcompat requires stateful server for legacy JSON-RPC."); - ConfigureExperimentalServer(MrtrElicit); + ConfigureServer(MrtrElicit); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs index da6357990..3400566fb 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs @@ -240,8 +240,37 @@ private static async Task AssertSingleSseResponseAsync(HttpResp return jsonRpcResponse; } - private Task PostJsonRpcAsync(string json) => - HttpClient.PostAsync("", JsonContent(json), TestContext.Current.CancellationToken); + private Task PostJsonRpcAsync(string json) + { + var content = JsonContent(json); + + // DRAFT-2026-v1 requires Mcp-Method and (for tools/call) Mcp-Name headers per SEP-2243. + // Parse the body to derive them and attach to this request only. + var bodyNode = JsonNode.Parse(json); + if (bodyNode is JsonObject obj) + { + if (obj["method"]?.GetValue() is { } method) + { + content.Headers.Add("Mcp-Method", method); + + if (obj["params"] is JsonObject paramsObj) + { + string? mcpName = method switch + { + "tools/call" or "prompts/get" => paramsObj["name"]?.GetValue(), + "resources/read" => paramsObj["uri"]?.GetValue(), + _ => null, + }; + if (mcpName is not null) + { + content.Headers.Add("Mcp-Name", mcpName); + } + } + } + } + + return HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); + } private long _lastRequestId = 1; From 8f87ad438414c241e2ad335848c4f884be1aca1a Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Tue, 19 May 2026 15:06:23 -0700 Subject: [PATCH 05/14] Add SEP-2322 MRTR conformance scenarios (ephemeral) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/Common/Utils/NodeHelpers.cs | 38 +++ .../ServerConformanceTests.cs | 28 ++ .../Program.cs | 2 + .../Prompts/IncompleteResultPrompts.cs | 68 +++++ .../Tools/IncompleteResultTools.cs | 279 ++++++++++++++++++ 5 files changed, 415 insertions(+) create mode 100644 tests/ModelContextProtocol.ConformanceServer/Prompts/IncompleteResultPrompts.cs create mode 100644 tests/ModelContextProtocol.ConformanceServer/Tools/IncompleteResultTools.cs diff --git a/tests/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs index a30dd3fc3..ef1686abb 100644 --- a/tests/Common/Utils/NodeHelpers.cs +++ b/tests/Common/Utils/NodeHelpers.cs @@ -205,6 +205,44 @@ public static bool HasSep2243Scenarios() } } + /// + /// Checks whether the SEP-2322 (Multi Round-Trip Requests / IncompleteResult) + /// conformance scenarios are available by reading the conformance package version + /// from the repo's package.json. MRTR scenarios require a conformance package version + /// that includes SEP-2322 support (see + /// https://github.com/modelcontextprotocol/conformance/pull/188). + /// + public static bool HasMrtrScenarios() + { + try + { + var repoRoot = FindRepoRoot(); + var packageJsonPath = Path.Combine(repoRoot, "package.json"); + if (!File.Exists(packageJsonPath)) + { + return false; + } + + var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(packageJsonPath)); + if (json.RootElement.TryGetProperty("dependencies", out var deps) && + deps.TryGetProperty("@modelcontextprotocol/conformance", out var versionElement)) + { + var versionStr = versionElement.GetString(); + if (versionStr is not null && Version.TryParse(versionStr, out var version)) + { + // SEP-2322 scenarios are expected in conformance package >= 0.2.0 + return version >= new Version(0, 2, 0); + } + } + + return false; + } + catch + { + return false; + } + } + private static ProcessStartInfo NpmStartInfo(string arguments, string workingDirectory) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs index 98cc5971a..ea4187a95 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs @@ -159,6 +159,34 @@ public async Task RunConformanceTest_HttpCustomHeaderServerValidation() $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); } + // SEP-2322 (Multi Round-Trip Requests / IncompleteResult) conformance scenarios. + // The csharp-sdk ConformanceServer surfaces the matching tools/prompts via + // ConformanceServer.Tools.IncompleteResultTools and ConformanceServer.Prompts.IncompleteResultPrompts. + // Each scenario uses the conformance harness's RawMcpSession, which negotiates DRAFT-2026-v1 + // so the csharp-sdk emits InputRequiredResult on the wire. These tests skip until the + // upstream conformance package ships with SEP-2322 scenarios + // (https://github.com/modelcontextprotocol/conformance/pull/188). + [Theory] + [InlineData("incomplete-result-basic-elicitation")] + [InlineData("incomplete-result-basic-sampling")] + [InlineData("incomplete-result-basic-list-roots")] + [InlineData("incomplete-result-request-state")] + [InlineData("incomplete-result-multiple-input-requests")] + [InlineData("incomplete-result-multi-round")] + [InlineData("incomplete-result-missing-input-response")] + [InlineData("incomplete-result-non-tool-request")] + public async Task RunMrtrConformanceTest(string scenario) + { + Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); + Assert.SkipWhen(!NodeHelpers.HasMrtrScenarios(), "SEP-2322 MRTR conformance scenarios not yet available in the published @modelcontextprotocol/conformance package."); + + var result = await RunConformanceTestsAsync( + $"server --url {fixture.ServerUrl} --scenario {scenario}"); + + Assert.True(result.Success, + $"MRTR conformance test '{scenario}' failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); + } + private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync(string arguments) { var startInfo = NodeHelpers.ConformanceTestStartInfo(arguments); diff --git a/tests/ModelContextProtocol.ConformanceServer/Program.cs b/tests/ModelContextProtocol.ConformanceServer/Program.cs index 017ec235f..f30d58a4d 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Program.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Program.cs @@ -31,6 +31,7 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide .WithHttpTransport() .WithDistributedCacheEventStreamStore() .WithTools() + .WithTools() .WithTools([ConformanceTools.CreateJsonSchema202012Tool()]) .WithRequestFilters(filters => filters.AddCallToolFilter(next => async (request, cancellationToken) => { @@ -47,6 +48,7 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide return result; })) .WithPrompts() + .WithPrompts() .WithResources() .WithSubscribeToResourcesHandler(async (ctx, ct) => { diff --git a/tests/ModelContextProtocol.ConformanceServer/Prompts/IncompleteResultPrompts.cs b/tests/ModelContextProtocol.ConformanceServer/Prompts/IncompleteResultPrompts.cs new file mode 100644 index 000000000..1e54ab645 --- /dev/null +++ b/tests/ModelContextProtocol.ConformanceServer/Prompts/IncompleteResultPrompts.cs @@ -0,0 +1,68 @@ +#pragma warning disable MCPEXP001 // MRTR (SEP-2322) is experimental. + +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.Text.Json; + +namespace ConformanceServer.Prompts; + +/// +/// Prompt implementing the SEP-2322 D1 conformance scenario (incomplete-result-non-tool-request), +/// proving that prompts/get can return an just like +/// tools/call. +/// +[McpServerPromptType] +public sealed class IncompleteResultPrompts +{ + [McpServerPrompt(Name = "test_incomplete_result_prompt")] + [Description("SEP-2322 D1: prompts/get returns IncompleteResult until user_context is supplied.")] + public static GetPromptResult IncompleteResultPrompt(RequestContext context) + { + if (context.Params!.InputResponses is { } responses && + responses.TryGetValue("user_context", out var response)) + { + var elicit = response.ElicitationResult; + var contextValue = TryReadString(elicit?.Content, "context") ?? "(unknown)"; + return new GetPromptResult + { + Description = "Prompt customized with elicited user context.", + Messages = + [ + new PromptMessage + { + Role = Role.User, + Content = new TextContentBlock { Text = $"Please continue using context: {contextValue}" }, + }, + ], + }; + } + + throw new InputRequiredException( + new Dictionary + { + ["user_context"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "What context should the prompt use?", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["context"] = new ElicitRequestParams.StringSchema(), + }, + Required = ["context"], + }, + }), + }); + } + + private static string? TryReadString(IDictionary? content, string key) + { + if (content is null || !content.TryGetValue(key, out var element)) + { + return null; + } + return element.ValueKind == JsonValueKind.String ? element.GetString() : element.ToString(); + } +} diff --git a/tests/ModelContextProtocol.ConformanceServer/Tools/IncompleteResultTools.cs b/tests/ModelContextProtocol.ConformanceServer/Tools/IncompleteResultTools.cs new file mode 100644 index 000000000..f244c202f --- /dev/null +++ b/tests/ModelContextProtocol.ConformanceServer/Tools/IncompleteResultTools.cs @@ -0,0 +1,279 @@ +#pragma warning disable MCPEXP001 // MRTR (SEP-2322) is experimental. + +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ConformanceServer.Tools; + +/// +/// Tools implementing the SEP-2322 (MRTR / IncompleteResult) conformance scenarios from +/// incomplete-result.ts in the conformance test suite. All tools use the low-level +/// API so they work both in stateful sessions with +/// MRTR-aware clients and in legacy-resolve mode (the SDK will translate exceptions to the +/// proper wire shape based on negotiated protocol version). +/// +[McpServerToolType] +public sealed class IncompleteResultTools +{ + // ──── A1: Basic Elicitation ───────────────────────────────────────────── + [McpServerTool(Name = "test_tool_with_elicitation")] + [Description("SEP-2322 A1: returns IncompleteResult with elicitation/create keyed 'user_name'.")] + public static CallToolResult ToolWithElicitation(RequestContext context) + { + if (context.Params!.InputResponses is { } responses && + responses.TryGetValue("user_name", out var response)) + { + var elicit = response.ElicitationResult; + var name = TryReadString(elicit?.Content, "name") ?? "world"; + return TextResult($"Hello, {name}!"); + } + + throw new InputRequiredException( + new Dictionary + { + ["user_name"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "What is your name?", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["name"] = new ElicitRequestParams.StringSchema(), + }, + Required = ["name"], + }, + }), + }); + } + + // ──── A2: Basic Sampling ──────────────────────────────────────────────── + [McpServerTool(Name = "test_incomplete_result_sampling")] + [Description("SEP-2322 A2: returns IncompleteResult with sampling/createMessage keyed 'capital_question'.")] + public static CallToolResult ToolWithSampling(RequestContext context) + { + if (context.Params!.InputResponses is { } responses && + responses.TryGetValue("capital_question", out var response)) + { + var text = response.SamplingResult?.Content?.OfType().FirstOrDefault()?.Text ?? "(no text)"; + return TextResult($"Sampling said: {text}"); + } + + throw new InputRequiredException( + new Dictionary + { + ["capital_question"] = InputRequest.ForSampling(new CreateMessageRequestParams + { + Messages = + [ + new SamplingMessage + { + Role = Role.User, + Content = [new TextContentBlock { Text = "What is the capital of France?" }], + }, + ], + MaxTokens = 100, + }), + }); + } + + // ──── A3: Basic ListRoots ─────────────────────────────────────────────── + [McpServerTool(Name = "test_incomplete_result_list_roots")] + [Description("SEP-2322 A3: returns IncompleteResult with roots/list keyed 'client_roots'.")] + public static CallToolResult ToolWithListRoots(RequestContext context) + { + if (context.Params!.InputResponses is { } responses && + responses.TryGetValue("client_roots", out var response)) + { + var count = response.RootsResult?.Roots?.Count ?? 0; + return TextResult($"Got {count} root(s) from the client."); + } + + throw new InputRequiredException( + new Dictionary + { + ["client_roots"] = InputRequest.ForRootsList(new ListRootsRequestParams()), + }); + } + + // ──── B1: requestState round-trip ─────────────────────────────────────── + private const string RequestStateToken = "mrtr-conformance-state-v1"; + + [McpServerTool(Name = "test_incomplete_result_request_state")] + [Description("SEP-2322 B1: round-trips a requestState string; R2 echoes 'state-ok' on success.")] + public static CallToolResult ToolWithRequestState(RequestContext context) + { + if (context.Params!.RequestState is { } state) + { + if (state != RequestStateToken) + { + return TextResult("state-mismatch: client echoed an unexpected requestState"); + } + return TextResult("state-ok: server received and validated the echoed requestState"); + } + + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "Please confirm", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["ok"] = new ElicitRequestParams.BooleanSchema(), + }, + Required = ["ok"], + }, + }), + }, + requestState: RequestStateToken); + } + + // ──── B2: Multiple input requests in one round ────────────────────────── + [McpServerTool(Name = "test_incomplete_result_multiple_inputs")] + [Description("SEP-2322 B2: returns 3 simultaneous inputRequests (elicit + sampling + roots) plus requestState.")] + public static CallToolResult ToolWithMultipleInputs(RequestContext context) + { + if (context.Params!.InputResponses is { } responses && responses.Count >= 3) + { + return TextResult("multiple-inputs-ok: received elicit + sampling + roots responses"); + } + + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["user_name"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "What is your name?", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["name"] = new ElicitRequestParams.StringSchema(), + }, + Required = ["name"], + }, + }), + ["greeting"] = InputRequest.ForSampling(new CreateMessageRequestParams + { + Messages = + [ + new SamplingMessage + { + Role = Role.User, + Content = [new TextContentBlock { Text = "Generate a greeting" }], + }, + ], + MaxTokens = 50, + }), + ["client_roots"] = InputRequest.ForRootsList(new ListRootsRequestParams()), + }, + requestState: "multi-input-state"); + } + + // ──── B3: Multi-round (R1 -> incomplete, R2 -> incomplete (new state), R3 -> complete) ───── + [McpServerTool(Name = "test_incomplete_result_multi_round")] + [Description("SEP-2322 B3: three-round flow whose requestState changes between rounds.")] + public static CallToolResult ToolWithMultiRound(RequestContext context) + { + var state = context.Params!.RequestState; + if (state is null) + { + // Round 1: elicit name. + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["step1"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "Step 1: What is your name?", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["name"] = new ElicitRequestParams.StringSchema(), + }, + Required = ["name"], + }, + }), + }, + requestState: "round-1"); + } + + if (state == "round-1") + { + // Round 2: elicit color (new state). + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["step2"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "Step 2: What is your favorite color?", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["color"] = new ElicitRequestParams.StringSchema(), + }, + Required = ["color"], + }, + }), + }, + requestState: "round-2"); + } + + // Round 3: complete. + return TextResult("multi-round-ok"); + } + + // ──── C1: Missing/wrong inputResponses key — re-request rather than error ──── + [McpServerTool(Name = "test_incomplete_result_elicitation")] + [Description("SEP-2322 C1: re-requests missing inputResponses key instead of erroring.")] + public static CallToolResult ToolForMissingResponse(RequestContext context) + { + if (context.Params!.InputResponses is { } responses && + responses.TryGetValue("user_name", out var response)) + { + var elicit = response.ElicitationResult; + var name = TryReadString(elicit?.Content, "name") ?? "world"; + return TextResult($"Hello, {name}!"); + } + + // Either no inputResponses or wrong key — re-request via a fresh InputRequiredResult + // (per SEP-2322 recommendation in scenario C1). + throw new InputRequiredException( + new Dictionary + { + ["user_name"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "What is your name?", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + ["name"] = new ElicitRequestParams.StringSchema(), + }, + Required = ["name"], + }, + }), + }); + } + + private static CallToolResult TextResult(string text) => new() + { + Content = [new TextContentBlock { Text = text }], + }; + + private static string? TryReadString(IDictionary? content, string key) + { + if (content is null || !content.TryGetValue(key, out var element)) + { + return null; + } + return element.ValueKind == JsonValueKind.String ? element.GetString() : element.ToString(); + } +} From 18c0df7b52d57baa286eecc0081208cb9fe226f3 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Wed, 27 May 2026 09:39:37 -0700 Subject: [PATCH 06/14] Remove implicit MRTR machinery and require InputRequiredException under draft Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/elicitation/elicitation.md | 33 +- docs/concepts/mrtr/mrtr.md | 269 ++------ docs/concepts/roots/roots.md | 24 +- docs/concepts/sampling/sampling.md | 33 +- .../Server/AIFunctionMcpServerTool.cs | 16 +- .../Server/DeferredTaskCreationResult.cs | 27 - .../Server/DeferredTaskInfo.cs | 78 --- .../Server/DelegatingMcpServerTool.cs | 5 +- .../Server/DestinationBoundMcpServer.cs | 66 +- .../Server/McpServer.Methods.cs | 27 + .../Server/McpServer.cs | 34 - .../Server/McpServerImpl.cs | 513 +-------------- .../Server/McpServerTool.cs | 7 - .../Server/McpServerToolAttribute.cs | 26 +- .../Server/McpServerToolCreateOptions.cs | 15 - .../Server/MrtrContext.cs | 85 --- .../Server/MrtrContinuation.cs | 50 -- .../Server/MrtrExchange.cs | 41 -- .../MapMcpTests.Mrtr.cs | 12 +- .../MrtrProtocolTests.cs | 89 --- .../McpClientDeferredTaskCreationTests.cs | 334 ---------- .../Client/MrtrIntegrationTests.cs | 621 ------------------ .../Server/DraftProtocolGuardTests.cs | 109 +++ .../Server/McpServerTests.cs | 2 +- .../Server/MrtrHandlerLifecycleTests.cs | 438 ------------ .../Server/MrtrMessageFilterTests.cs | 149 ----- .../Server/MrtrSessionLimitTests.cs | 183 ------ .../Server/MrtrTaskIntegrationTests.cs | 295 --------- 28 files changed, 232 insertions(+), 3349 deletions(-) delete mode 100644 src/ModelContextProtocol.Core/Server/DeferredTaskCreationResult.cs delete mode 100644 src/ModelContextProtocol.Core/Server/DeferredTaskInfo.cs delete mode 100644 src/ModelContextProtocol.Core/Server/MrtrContext.cs delete mode 100644 src/ModelContextProtocol.Core/Server/MrtrContinuation.cs delete mode 100644 src/ModelContextProtocol.Core/Server/MrtrExchange.cs delete mode 100644 tests/ModelContextProtocol.Tests/Client/McpClientDeferredTaskCreationTests.cs delete mode 100644 tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/DraftProtocolGuardTests.cs delete mode 100644 tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs delete mode 100644 tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs delete mode 100644 tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs delete mode 100644 tests/ModelContextProtocol.Tests/Server/MrtrTaskIntegrationTests.cs diff --git a/docs/concepts/elicitation/elicitation.md b/docs/concepts/elicitation/elicitation.md index 8e87c205b..22cc1bcd3 100644 --- a/docs/concepts/elicitation/elicitation.md +++ b/docs/concepts/elicitation/elicitation.md @@ -172,36 +172,15 @@ Here's an example implementation of how a console application might handle elici ### Multi Round-Trip Requests (MRTR) -When both the client and server opt in to the experimental [MRTR](xref:mrtr) protocol, elicitation requests are handled via incomplete result / retry instead of a direct JSON-RPC request. This is transparent — the existing `ElicitAsync` API works identically regardless of whether MRTR is active. +[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 only supported way to ask the user for input from a server handler is to throw and let the SDK emit an on the wire. -#### High-level API +> [!IMPORTANT] +> Calling `ElicitAsync` after negotiating `DRAFT-2026-v1` throws `InvalidOperationException`. Use `InputRequiredException` from your tool/prompt handler instead — it works under both the current protocol (resolved via legacy JSON-RPC + retry on stateful sessions) and the draft protocol (wire-format `InputRequiredResult`, no server-side handler state required). -No code changes are needed. `ElicitAsync` automatically uses MRTR when both sides have opted in, and falls back to legacy JSON-RPC requests otherwise: +For example: ```csharp -// This code works the same with or without MRTR — the SDK handles it transparently. -var result = await server.ElicitAsync(new ElicitRequestParams -{ - Message = "Please confirm the action", - RequestedSchema = new() - { - Properties = new Dictionary - { - ["confirm"] = new ElicitRequestParams.BooleanSchema - { - Description = "Confirm the action" - } - } - } -}, cancellationToken); -``` - -#### Low-level API - -For stateless servers or scenarios requiring manual control, throw with an elicitation input request. On retry, read the client's response from : - -```csharp -[McpServerTool, Description("Tool that elicits via low-level MRTR")] +[McpServerTool, Description("Tool that elicits via MRTR")] public static string ElicitWithMrtr( McpServer server, RequestContext context) @@ -217,7 +196,7 @@ public static string ElicitWithMrtr( if (!server.IsMrtrSupported) { - return "This tool requires MRTR support."; + return "This tool requires MRTR support (DRAFT-2026-v1, or a stateful current-protocol session)."; } // First call — request user input diff --git a/docs/concepts/mrtr/mrtr.md b/docs/concepts/mrtr/mrtr.md index 87203c162..607a322bc 100644 --- a/docs/concepts/mrtr/mrtr.md +++ b/docs/concepts/mrtr/mrtr.md @@ -9,47 +9,37 @@ uid: mrtr > [!WARNING] -> MRTR is an **experimental feature** based on a draft MCP specification proposal. The API may change in future releases. See the [Experimental APIs](../../experimental.md) documentation for details on working with experimental APIs. Both the client and server must opt in via and respectively. +> MRTR is part of the **`DRAFT-2026-v1`** revision of the MCP specification ([SEP-2322](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322)). The wire format and API surface may change before the revision is ratified. See the [Experimental APIs](../../experimental.md) documentation for details on working with experimental APIs. -Multi Round-Trip Requests (MRTR) allow a server tool to request input from the client — such as [elicitation](xref:elicitation), [sampling](xref:sampling), or [roots](xref:roots) — as part of a single tool call, without requiring a separate JSON-RPC request for each interaction. Instead of sending a final result, the server returns an **incomplete result** containing one or more input requests. The client fulfills those requests and retries the original tool call with the responses attached. +Multi Round-Trip Requests (MRTR) let a server tool request input from the client — such as [elicitation](xref:elicitation), [sampling](xref:sampling), or [roots](xref:roots) — as part of a single tool call, without requiring a separate server-to-client JSON-RPC request for each interaction. Instead of returning a final result, the server returns an **incomplete result** containing one or more input requests. The client fulfills those requests and retries the original tool call with the responses attached. ## Overview MRTR is useful when: -- A tool needs user confirmation before proceeding (elicitation) -- A tool needs LLM reasoning from the client (sampling) -- A tool needs an updated list of client roots -- A tool needs to perform multiple rounds of interaction in a single logical operation -- A stateless server needs to orchestrate multi-step flows without keeping handler state in memory +- A tool needs user confirmation before proceeding (elicitation). +- A tool needs LLM reasoning from the client (sampling). +- A tool needs an updated list of client roots. +- A tool needs to perform multiple rounds of interaction in a single logical operation. +- A stateless server needs to orchestrate multi-step flows without keeping handler state in memory between rounds. ## How MRTR works 1. The client calls a tool on the server via `tools/call`. 2. The server tool determines it needs client input and returns an `InputRequiredResult` containing `inputRequests` and/or `requestState`. -3. The client resolves each input request (e.g., prompts the user for elicitation, calls an LLM for sampling). +3. The client resolves each input request (for example by prompting the user for elicitation, calling an LLM for sampling, or listing its roots). 4. The client retries the original `tools/call` with `inputResponses` (keyed to the input requests) and `requestState` echoed back. 5. The server processes the responses and either returns a final result or another `InputRequiredResult` for additional rounds. ## Opting in -MRTR requires both the client and server to opt in by setting `ExperimentalProtocolVersion` to a draft protocol version. Currently, this is `"2026-06-XX"`: - -```csharp -// Server -var builder = Host.CreateApplicationBuilder(); -builder.Services.AddMcpServer(options => -{ - options.ExperimentalProtocolVersion = "2026-06-XX"; -}) -.WithTools(); -``` +MRTR activates when both peers negotiate protocol revision **`DRAFT-2026-v1`** during `initialize`. The C# SDK opts in by listing `DRAFT-2026-v1` as a supported protocol version on the client; servers automatically accept it when offered. No experimental flags are required. ```csharp // Client -var options = new McpClientOptions +var clientOptions = new McpClientOptions { - ExperimentalProtocolVersion = "2026-06-XX", + ProtocolVersion = "DRAFT-2026-v1", Handlers = new McpClientHandlers { ElicitationHandler = HandleElicitationAsync, @@ -58,70 +48,31 @@ var options = new McpClientOptions }; ``` -When both sides opt in, the negotiated protocol version activates MRTR. When either side does not opt in, the SDK gracefully falls back to standard behavior. - -## High-level API - -The high-level API lets tool handlers call and as if they were simple async calls. The SDK transparently manages the incomplete result / retry cycle. - -```csharp -[McpServerToolType] -public class InteractiveTools -{ - [McpServerTool, Description("Asks the user for confirmation before proceeding")] - public static async Task ConfirmAction( - McpServer server, - [Description("The action to confirm")] string action, - CancellationToken cancellationToken) - { - var result = await server.ElicitAsync(new ElicitRequestParams - { - Message = $"Do you want to proceed with: {action}?", - RequestedSchema = new() - { - Properties = new Dictionary - { - ["confirm"] = new ElicitRequestParams.BooleanSchema - { - Description = "Confirm the action" - } - } - } - }, cancellationToken); - - return result.Action == "accept" ? "Action confirmed!" : "Action cancelled."; - } -} -``` - -From the client's perspective, this is a single `CallToolAsync` call. The SDK handles all retries automatically: - -```csharp -var result = await client.CallToolAsync("ConfirmAction", new { action = "delete all files" }); -Console.WriteLine(result.Content.OfType().First().Text); -``` +Under `DRAFT-2026-v1`, MRTR is the **only** way to obtain client input from a server handler. The legacy server-to-client `elicitation/create`, `sampling/createMessage`, and `roots/list` request methods are removed; calling , , or on a server that negotiated `DRAFT-2026-v1` throws `InvalidOperationException`. Tools that need client input must throw instead. -> [!TIP] -> The high-level API requires session affinity — the handler task stays suspended in server memory between round trips. This works well for stateful (non-stateless) server configurations. +Under the current protocol revision (`2025-06-18` and earlier), `InputRequiredException` is still supported in stateful sessions via a backward-compatibility resolver — see [Compatibility](#compatibility) below. -## Low-level API +## Authoring an MRTR tool -The low-level API gives tool handlers direct control over `inputRequests` and `requestState`. This enables stateless multi-round-trip flows where the server does not need to keep handler state in memory between retries. +A tool participates in MRTR by throwing with an describing what it needs. On retry, the client's responses arrive on the request parameters and the tool inspects them to decide what to do next. ### Checking MRTR support -Before using the low-level API, check to determine if the connected client supports MRTR. If it does not, provide a fallback experience: +Tools should check before throwing `InputRequiredException`. It returns `true` when either: + +- The negotiated protocol revision is `DRAFT-2026-v1` (MRTR is native), or +- The session is stateful under the current protocol (the SDK can resolve input requests via legacy JSON-RPC and retry the handler). ```csharp -[McpServerTool, Description("A tool that uses low-level MRTR")] +[McpServerTool, Description("A tool that uses MRTR")] public static string MyTool( McpServer server, RequestContext context) { if (!server.IsMrtrSupported) { - return "This tool requires a client that supports multi-round-trip requests. " - + "Please upgrade your client or enable experimental protocol support."; + return "This tool requires a client that negotiates DRAFT-2026-v1, " + + "or a stateful current-protocol session."; } // ... MRTR logic @@ -130,11 +81,11 @@ public static string MyTool( ### Returning an incomplete result -Throw to return an incomplete result to the client. The exception carries an containing `inputRequests` and/or `requestState`: +Throw to return an incomplete result. The exception carries an containing `inputRequests` and/or `requestState`: ```csharp -[McpServerTool, Description("Stateless tool managing its own MRTR flow")] -public static string StatelessTool( +[McpServerTool, Description("Tool managing its own MRTR flow")] +public static string AnswerTool( McpServer server, RequestContext context, [Description("The user's question")] string question) @@ -181,14 +132,14 @@ public static string StatelessTool( When the client retries a tool call, the retry data is available on the request parameters: -- — a dictionary of client responses keyed by the same keys used in `inputRequests` -- — the opaque state string echoed back by the client +- — a dictionary of client responses keyed by the same keys used in `inputRequests`. +- — the opaque state string echoed back by the client. Each `InputResponse` has typed accessors for the response type: -- `ElicitationResult` — the result of an elicitation request -- `SamplingResult` — the result of a sampling request -- `RootsResult` — the result of a roots list request +- `ElicitationResult` — the result of an elicitation request. +- `SamplingResult` — the result of a sampling request. +- `RootsResult` — the result of a roots list request. ### Load shedding with requestState-only responses @@ -227,7 +178,7 @@ The client automatically retries `requestState`-only incomplete results, echoing ### Multiple round trips -A tool can perform multiple rounds of interaction by throwing `InputRequiredException` multiple times across retries: +A tool can perform multiple rounds of interaction by throwing `InputRequiredException` multiple times across retries. Use `requestState` to track which round you're on: ```csharp [McpServerTool, Description("Multi-step wizard")] @@ -306,159 +257,35 @@ When MRTR is not supported, you can provide domain-specific guidance: ```csharp if (!server.IsMrtrSupported) { - return "This tool requires interactive input, but your client doesn't support " - + "multi-round-trip requests. To use this feature:\n" - + "1. Update to a client that supports MCP protocol version 2026-06-XX or later\n" - + "2. Enable the experimental protocol version in your client configuration\n" - + "\nFor more information, see: https://example.com/mrtr-setup"; + return "This tool requires interactive input. To use it:\n" + + "1. Connect with a client that negotiates MCP protocol revision DRAFT-2026-v1, or\n" + + "2. Use a stateful current-protocol session so the server can resolve the input requests for you.\n" + + "\nStateless current-protocol sessions cannot resolve MRTR input requests."; } ``` ## Compatibility -The SDK handles all four combinations of experimental/non-experimental client and server: +The SDK supports `InputRequiredException` across two protocol revisions and two session modes: -| Server Experimental | Client Experimental | Behavior | +| Negotiated protocol | Session mode | Behavior | |---|---|---| -| ✅ | ✅ | MRTR — incomplete results with retry cycle | -| ✅ | ❌ | Server falls back to legacy JSON-RPC requests for elicitation/sampling | -| ❌ | ✅ | Client accepts stable protocol version; MRTR retry loop is a no-op | -| ❌ | ❌ | Standard behavior — no MRTR | - -When a server has MRTR enabled but the connected client does not: - -- The high-level API (`ElicitAsync`, `SampleAsync`) automatically falls back to sending standard JSON-RPC requests — no code changes needed. -- The low-level API reports `IsMrtrSupported == false`, allowing the tool to provide a custom fallback message. - -### Backward compatibility for MRTR-native tools - -Tools written with the low-level MRTR pattern (`InputRequiredException`) work automatically with clients that don't support MRTR. When a tool throws `InputRequiredException` 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. - -This means you can write a single tool implementation using the MRTR-native pattern and it will work with any client: - -```csharp -[McpServerTool, Description("Get weather with user's preferred units")] -public static string GetWeather( - RequestContext context, - string location) -{ - // On retry, inputResponses and requestState are populated - if (context.Params!.InputResponses?.TryGetValue("units", out var response) == true) - { - var units = response.ElicitationResult?.Content?.FirstOrDefault().Value; - return $"Weather for {location} in {units}: 72°"; - } - - // First call: request the user's preferred units - throw new InputRequiredException( - inputRequests: new Dictionary - { - ["units"] = InputRequest.ForElicitation(new ElicitRequestParams - { - Message = "Which temperature units?", - RequestedSchema = new() - }) - }, - requestState: "awaiting-units"); -} -``` - -- **With an MRTR client**: The `InputRequiredResult` is sent over the wire. The client resolves the elicitation and retries with `inputResponses`. -- **Without MRTR**: The SDK sends a standard `elicitation/create` JSON-RPC request to the client, collects the response, and retries the handler internally. The client never sees the `InputRequiredResult`. +| `DRAFT-2026-v1` | Stateful | Native MRTR — `InputRequiredResult` is serialized directly to the wire. | +| `DRAFT-2026-v1` | Stateless | Native MRTR — `InputRequiredResult` is serialized directly to the wire. No server-side handler state needed. | +| Current (`2025-06-18` and earlier) | Stateful | Backward-compatibility resolver — the SDK sends standard `elicitation/create` / `sampling/createMessage` / `roots/list` JSON-RPC requests to the client, collects the responses, and retries the handler with `inputResponses` populated. Up to 10 retry rounds. | +| Current (`2025-06-18` and earlier) | Stateless | **Not supported** — `InputRequiredException` raises an `McpException`. The client doesn't speak MRTR, and the server can't resolve input requests via JSON-RPC without a persistent session. | > [!NOTE] -> The backcompat retry loop resolves up to 10 rounds. Tools that need more rounds should use the high-level API (`ElicitAsync`) instead. +> The backcompat resolver is intentionally limited to 10 retry rounds. Tools that need more rounds should require `DRAFT-2026-v1` (check `IsMrtrSupported`). -## Transitioning from MRTR to Tasks +### Why `ElicitAsync` / `SampleAsync` / `RequestRootsAsync` throw under draft - -> [!WARNING] -> Deferred task creation depends on both the [MRTR](xref:mrtr) and [Tasks](xref:tasks) experimental features. - -Some tools need user input before they can decide whether to start a long-running background task. For example, a VM provisioning tool might confirm costs with the user before committing to a task that takes minutes. **Deferred task creation** lets a tool perform ephemeral MRTR exchanges first, then transition to a background task only when ready. - -### How it works +The `DRAFT-2026-v1` revision removes the server-to-client `elicitation/create`, `sampling/createMessage`, and `roots/list` request methods entirely. Servers cannot use those request methods because clients no longer advertise the corresponding capabilities or implement handlers for them. The SDK fails fast with a clear `InvalidOperationException` so you can fix the call site before it manifests as a wire-level error. -1. The tool sets `DeferTaskCreation = true` on its attribute or options. -2. When the client sends task metadata with the `tools/call` request, the SDK runs the tool through the normal MRTR-wrapped path instead of creating a task immediately. -3. The tool calls `ElicitAsync` or `SampleAsync` as usual — these use MRTR (incomplete result / retry cycles). -4. When the tool is ready, it calls `await server.CreateTaskAsync(cancellationToken)` to transition to a background task. -5. After `CreateTaskAsync`, the MRTR phase ends. Any subsequent `ElicitAsync` or `SampleAsync` calls use the task's own `input_required` / `tasks/input_response` mechanism instead. -6. If the tool returns without calling `CreateTaskAsync`, a normal (non-task) result is sent to the client. +Under the current protocol revision (`2025-06-18` and earlier), these methods continue to work normally and are the recommended way to do simple, one-shot client interactions. `InputRequiredException` is the way to write tools that work the same on both revisions. -### Server example - -```csharp -McpServerTool.Create( - async (string vmName, McpServer server, CancellationToken ct) => - { - // Phase 1: Ephemeral MRTR — confirm with user before starting expensive work. - var confirmation = await server.ElicitAsync(new ElicitRequestParams - { - Message = $"Provision VM '{vmName}'? This will incur costs.", - RequestedSchema = new() - }, ct); - - if (confirmation.Action != "confirm") - { - return "Cancelled by user."; - } +### Future direction - // Phase 2: Transition to a background task. - await server.CreateTaskAsync(ct); +The `DRAFT-2026-v1` revision is moving toward a stateless-only model: `Mcp-Session-Id` is being removed, and Streamable HTTP servers will run statelessly by default under the draft revision. When that happens, the `Stateful` row of the compatibility matrix above collapses into the `Stateless` row, and `InputRequiredException` becomes uniformly native across both. The current-protocol resolver path will remain for backward compatibility with older clients and stateful servers. - // Phase 3: Background work — runs as a task, client polls for status. - await Task.Delay(TimeSpan.FromMinutes(5), ct); - return $"VM '{vmName}' provisioned successfully."; - }, - new McpServerToolCreateOptions - { - Name = "provision-vm", - Description = "Provisions a VM with user confirmation", - DeferTaskCreation = true, - Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional }, - }) -``` - -The attribute-based equivalent uses `DeferTaskCreation` on : - -```csharp -[McpServerTool(DeferTaskCreation = true, TaskSupport = ToolTaskSupport.Optional)] -[Description("Provisions a VM with user confirmation")] -public static async Task ProvisionVm( - string vmName, McpServer server, CancellationToken ct) -{ - var confirmation = await server.ElicitAsync(new ElicitRequestParams - { - Message = $"Provision VM '{vmName}'? This will incur costs.", - RequestedSchema = new() - }, ct); - - if (confirmation.Action != "confirm") - return "Cancelled by user."; - - await server.CreateTaskAsync(ct); - - await Task.Delay(TimeSpan.FromMinutes(5), ct); - return $"VM '{vmName}' provisioned successfully."; -} -``` - -### Key points - -- **One-way transition**: Once `CreateTaskAsync` is called, the tool cannot go back to ephemeral MRTR. All subsequent input requests use the task workflow. -- **Optional task creation**: A `DeferTaskCreation` tool can return a normal result without ever calling `CreateTaskAsync`. The tool decides at runtime whether to create a task. -- **No task metadata, no deferral**: If the client calls the tool without task metadata, the tool runs normally with MRTR — `DeferTaskCreation` has no effect. - -For more details on task configuration and lifecycle, see the [Tasks](xref:tasks) documentation. - -## Choosing between high-level and low-level APIs - -| Consideration | High-level API | Low-level API | -|---|---|---| -| **Session affinity** | Required — handler stays suspended in memory | Not required — handler completes each round | -| **State management** | Automatic (SDK manages via `MrtrContext`) | Manual (`requestState` encoded by you) | -| **Complexity** | Simple `await` calls | More code, but full control | -| **Stateless servers** | Not compatible | Designed for stateless scenarios | -| **Fallback** | Automatic — SDK sends legacy requests | Manual — check `IsMrtrSupported` | -| **Multiple input types** | One at a time (elicit or sample) | Multiple in a single round | +This work is a follow-up to the present PR. diff --git a/docs/concepts/roots/roots.md b/docs/concepts/roots/roots.md index c2cc4efb6..2525b9b65 100644 --- a/docs/concepts/roots/roots.md +++ b/docs/concepts/roots/roots.md @@ -106,27 +106,15 @@ server.RegisterNotificationHandler( ### Multi Round-Trip Requests (MRTR) -When both the client and server opt in to the experimental [MRTR](xref:mrtr) protocol, root list requests are handled via incomplete result / retry instead of a direct JSON-RPC request. This is transparent — the existing `RequestRootsAsync` API works identically regardless of whether MRTR is active. +[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 `roots/list` request method is removed; the only supported way to ask the client for its roots from a server handler is to throw and let the SDK emit an on the wire. -#### High-level API +> [!IMPORTANT] +> Calling `RequestRootsAsync` after negotiating `DRAFT-2026-v1` throws `InvalidOperationException`. Use `InputRequiredException` from your tool/prompt handler instead — it works under both the current protocol (resolved via legacy JSON-RPC + retry on stateful sessions) and the draft protocol (wire-format `InputRequiredResult`, no server-side handler state required). -No code changes are needed. `RequestRootsAsync` automatically uses MRTR when both sides have opted in: +For example: ```csharp -// This code works the same with or without MRTR — the SDK handles it transparently. -var result = await server.RequestRootsAsync(new ListRootsRequestParams(), cancellationToken); -foreach (var root in result.Roots) -{ - Console.WriteLine($"Root: {root.Name ?? root.Uri}"); -} -``` - -#### Low-level API - -For stateless servers or scenarios requiring manual control, throw with a roots input request. On retry, read the client's response from : - -```csharp -[McpServerTool, Description("Tool that requests roots via low-level MRTR")] +[McpServerTool, Description("Tool that requests roots via MRTR")] public static string ListRootsWithMrtr( McpServer server, RequestContext context) @@ -140,7 +128,7 @@ public static string ListRootsWithMrtr( if (!server.IsMrtrSupported) { - return "This tool requires MRTR support."; + return "This tool requires MRTR support (DRAFT-2026-v1, or a stateful current-protocol session)."; } // First call — request the client's root list diff --git a/docs/concepts/sampling/sampling.md b/docs/concepts/sampling/sampling.md index 339a91c66..9387b3730 100644 --- a/docs/concepts/sampling/sampling.md +++ b/docs/concepts/sampling/sampling.md @@ -123,36 +123,15 @@ Sampling requires the client to advertise the `sampling` capability. This is han ### Multi Round-Trip Requests (MRTR) -When both the client and server opt in to the experimental [MRTR](xref:mrtr) protocol, sampling requests are handled via incomplete result / retry instead of a direct JSON-RPC request. This is transparent — the existing `SampleAsync` and `AsSamplingChatClient` APIs work identically regardless of whether MRTR is active. +[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 `sampling/createMessage` request method is removed; the only supported way to ask the client to sample from a server handler is to throw and let the SDK emit an on the wire. -#### High-level API +> [!IMPORTANT] +> Calling `SampleAsync` or `AsSamplingChatClient` after negotiating `DRAFT-2026-v1` throws `InvalidOperationException`. Use `InputRequiredException` from your tool/prompt handler instead — it works under both the current protocol (resolved via legacy JSON-RPC + retry on stateful sessions) and the draft protocol (wire-format `InputRequiredResult`, no server-side handler state required). -No code changes are needed. `SampleAsync` and `AsSamplingChatClient` automatically use MRTR when both sides have opted in, and fall back to legacy JSON-RPC requests otherwise: +For example: ```csharp -// This code works the same with or without MRTR — the SDK handles it transparently. -var result = await server.SampleAsync( - new CreateMessageRequestParams - { - Messages = - [ - new SamplingMessage - { - Role = Role.User, - Content = [new TextContentBlock { Text = "Summarize the data" }] - } - ], - MaxTokens = 256, - }, - cancellationToken); -``` - -#### Low-level API - -For stateless servers or scenarios requiring manual control, throw with a sampling input request. On retry, read the client's response from : - -```csharp -[McpServerTool, Description("Tool that samples via low-level MRTR")] +[McpServerTool, Description("Tool that samples via MRTR")] public static string SampleWithMrtr( McpServer server, RequestContext context) @@ -167,7 +146,7 @@ public static string SampleWithMrtr( if (!server.IsMrtrSupported) { - return "This tool requires MRTR support."; + return "This tool requires MRTR support (DRAFT-2026-v1, or a stateful current-protocol session)."; } // First call — request LLM completion from the client diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index c4090f99e..8fbf99581 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; using System.ComponentModel; @@ -15,7 +15,6 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool { private readonly bool _structuredOutputRequiresWrapping; private readonly IReadOnlyList _metadata; - private readonly bool _deferTaskCreation; /// /// Creates an instance for a method, specified via a instance. @@ -174,7 +173,7 @@ options.OpenWorld is not null || tool.Execution.TaskSupport = ToolTaskSupport.Optional; } - return new AIFunctionMcpServerTool(function, tool, options?.Services, structuredOutputRequiresWrapping, options?.Metadata ?? [], options?.DeferTaskCreation ?? false); + return new AIFunctionMcpServerTool(function, tool, options?.Services, structuredOutputRequiresWrapping, options?.Metadata ?? []); } private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpServerToolCreateOptions? options) @@ -225,11 +224,6 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe newOptions.Execution ??= new ToolExecution(); newOptions.Execution.TaskSupport ??= taskSupport; } - - if (toolAttr._deferTaskCreation is bool deferTaskCreation) - { - newOptions.DeferTaskCreation = deferTaskCreation; - } } if (method.GetCustomAttribute() is { } descAttr) @@ -247,7 +241,7 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe internal AIFunction AIFunction { get; } /// Initializes a new instance of the class. - private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider? serviceProvider, bool structuredOutputRequiresWrapping, IReadOnlyList metadata, bool deferTaskCreation) + private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider? serviceProvider, bool structuredOutputRequiresWrapping, IReadOnlyList metadata) { ValidateToolName(tool.Name); @@ -256,15 +250,11 @@ private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider _structuredOutputRequiresWrapping = structuredOutputRequiresWrapping; _metadata = metadata; - _deferTaskCreation = deferTaskCreation; } /// public override Tool ProtocolTool { get; } - /// - public override bool DeferTaskCreation => _deferTaskCreation; - /// public override IReadOnlyList Metadata => _metadata; diff --git a/src/ModelContextProtocol.Core/Server/DeferredTaskCreationResult.cs b/src/ModelContextProtocol.Core/Server/DeferredTaskCreationResult.cs deleted file mode 100644 index bd5b99f6e..000000000 --- a/src/ModelContextProtocol.Core/Server/DeferredTaskCreationResult.cs +++ /dev/null @@ -1,27 +0,0 @@ -using ModelContextProtocol.Protocol; - -namespace ModelContextProtocol.Server; - -/// -/// Contains the information the handler needs after the framework creates the deferred task. -/// -internal sealed class DeferredTaskCreationResult -{ - /// Gets the ID of the created task. - public required string TaskId { get; init; } - - /// Gets the session ID associated with the task. - public required string? SessionId { get; init; } - - /// Gets the task store for persisting task state. - public required IMcpTaskStore TaskStore { get; init; } - - /// Gets whether to send task status notifications. - public required bool SendNotifications { get; init; } - - /// Gets the function for sending task status notifications. - public required Func? NotifyTaskStatusFunc { get; init; } - - /// Gets the cancellation token for the task (TTL-based or explicit). - public required CancellationToken TaskCancellationToken { get; init; } -} diff --git a/src/ModelContextProtocol.Core/Server/DeferredTaskInfo.cs b/src/ModelContextProtocol.Core/Server/DeferredTaskInfo.cs deleted file mode 100644 index b14cf8059..000000000 --- a/src/ModelContextProtocol.Core/Server/DeferredTaskInfo.cs +++ /dev/null @@ -1,78 +0,0 @@ -using ModelContextProtocol.Protocol; - -namespace ModelContextProtocol.Server; - -/// -/// Holds the state needed for deferred task creation, where a tool handler performs -/// ephemeral MRTR exchanges before committing to a background task via -/// . -/// Stored on and carried across MRTR continuations. -/// -internal sealed class DeferredTaskInfo -{ - /// Gets the task metadata from the original client request. - public required McpTaskMetadata TaskMetadata { get; init; } - - /// Gets the JSON-RPC request ID of the current tools/call request. - public required RequestId OriginalRequestId { get; init; } - - /// Gets the original JSON-RPC request. - public required JsonRpcRequest OriginalRequest { get; init; } - - /// Gets the task store for persisting task state. - public required IMcpTaskStore TaskStore { get; init; } - - /// Gets whether to send task status notifications. - public required bool SendNotifications { get; init; } - - /// - /// Task that completes when the handler calls . - /// The framework races this against handler completion and MRTR exchanges. - /// - private readonly TaskCompletionSource _signalTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); - - /// - /// TCS that the framework completes after creating the task, allowing the handler to continue. - /// - private readonly TaskCompletionSource _ackTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); - - /// Gets the task that completes when the handler requests task creation. - public Task SignalTask => _signalTcs.Task; - - /// - /// Called by the handler (via ) to signal - /// the framework that a task should be created. Awaits the framework's acknowledgment. - /// - /// The result containing the created task's context information. - /// was already called. - public async ValueTask RequestTaskCreationAsync(CancellationToken cancellationToken) - { - if (!_signalTcs.TrySetResult(true)) - { - throw new InvalidOperationException("CreateTaskAsync has already been called for this tool execution."); - } - - return await _ackTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); - } - - /// - /// Called by the framework after creating the task to unblock the handler. - /// - /// Task creation was already acknowledged. - public void AcknowledgeTaskCreation(DeferredTaskCreationResult result) - { - if (!_ackTcs.TrySetResult(result)) - { - throw new InvalidOperationException("Task creation was already acknowledged."); - } - } - - /// - /// Called by the framework when task creation fails, propagating the exception - /// to the handler so throws. - /// - public void AcknowledgeFailure(Exception exception) - { - _ackTcs.TrySetException(exception); - } -} diff --git a/src/ModelContextProtocol.Core/Server/DelegatingMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/DelegatingMcpServerTool.cs index 79e46fe4a..3835d1b98 100644 --- a/src/ModelContextProtocol.Core/Server/DelegatingMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/DelegatingMcpServerTool.cs @@ -1,4 +1,4 @@ -using ModelContextProtocol.Protocol; +using ModelContextProtocol.Protocol; namespace ModelContextProtocol.Server; @@ -23,9 +23,6 @@ protected DelegatingMcpServerTool(McpServerTool innerTool) /// public override Tool ProtocolTool => _innerTool.ProtocolTool; - /// - public override bool DeferTaskCreation => _innerTool.DeferTaskCreation; - /// public override IReadOnlyList Metadata => _innerTool.Metadata; diff --git a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs index b33e22bb0..51ca91498 100644 --- a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs @@ -1,6 +1,4 @@ -using ModelContextProtocol.Protocol; -using System.Text.Json; -using System.Text.Json.Nodes; +using ModelContextProtocol.Protocol; namespace ModelContextProtocol.Server; @@ -16,12 +14,6 @@ internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport public override IServiceProvider? Services => server.Services; public override LoggingLevel? LoggingLevel => server.LoggingLevel; - /// - /// Gets or sets the MRTR context for the current request, if any. - /// Set by when an MRTR-aware handler invocation is in progress. - /// - internal MrtrContext? ActiveMrtrContext { get; set; } - public override bool IsMrtrSupported => server.IsLowLevelMrtrAvailable(); public override ValueTask DisposeAsync() => server.DisposeAsync(); @@ -48,16 +40,6 @@ public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken public override Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) { - // When an MRTR context is active, intercept server-to-client requests (sampling, elicitation, roots) - // and route them through the MRTR mechanism instead of sending them over the wire. - // Task-based requests (SampleAsTaskAsync/ElicitAsTaskAsync) have a "task" property on their params - // and expect a CreateTaskResult response, so they must bypass MRTR and go over the wire. - if (ActiveMrtrContext is { } mrtrContext && - !(request.Params is JsonObject paramsObj && paramsObj.ContainsKey("task"))) - { - return SendRequestViaMrtrAsync(mrtrContext, request, cancellationToken); - } - if (request.Context is not null) { throw new ArgumentException("Only transports can provide a JsonRpcMessageContext."); @@ -70,50 +52,4 @@ public override Task SendRequestAsync(JsonRpcRequest request, C return server.SendRequestAsync(request, cancellationToken); } - - private async Task SendRequestViaMrtrAsync( - MrtrContext mrtrContext, JsonRpcRequest request, CancellationToken cancellationToken) - { - var inputRequest = new InputRequest - { - Method = request.Method, - Params = request.Params is { } paramsNode - ? JsonSerializer.Deserialize(paramsNode, McpJsonUtilities.JsonContext.Default.JsonElement) - : null, - }; - var inputResponse = await mrtrContext.RequestInputAsync(inputRequest, cancellationToken).ConfigureAwait(false); - - return new JsonRpcResponse - { - Id = request.Id, - Result = JsonSerializer.SerializeToNode(inputResponse.RawValue, McpJsonUtilities.JsonContext.Default.JsonElement), - }; - } - - /// - public override async ValueTask CreateTaskAsync(CancellationToken cancellationToken = default) - { - var deferredTask = ActiveMrtrContext?.DeferredTask - ?? throw new InvalidOperationException( - "CreateTaskAsync can only be called from a tool handler with DeferTaskCreation enabled " + - "when the client provides task metadata in the tools/call request."); - - // Signal the framework to create the task and wait for acknowledgment. - // RequestTaskCreationAsync is atomic — throws if already called. - var result = await deferredTask.RequestTaskCreationAsync(cancellationToken).ConfigureAwait(false); - - // Transition to task mode on the handler's async flow. - TaskExecutionContext.Current = new TaskExecutionContext - { - TaskId = result.TaskId, - SessionId = result.SessionId, - TaskStore = result.TaskStore, - SendNotifications = result.SendNotifications, - NotifyTaskStatusFunc = result.NotifyTaskStatusFunc, - }; - - // No more ephemeral MRTR — subsequent ElicitAsync/SampleAsync calls - // will go through SendRequestWithTaskStatusTrackingAsync instead. - ActiveMrtrContext = null; - } } diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs index 3caaca5a6..413639273 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs @@ -63,6 +63,7 @@ public async ValueTask SampleAsync( CancellationToken cancellationToken = default) { Throw.IfNull(requestParams); + ThrowIfDraftProtocol(nameof(SampleAsync), "Throw 'InputRequiredException' from your handler with 'InputRequest.ForSampling(...)' instead."); ThrowIfSamplingUnsupported(); return await SendRequestWithTaskStatusTrackingAsync( @@ -96,6 +97,7 @@ public async ValueTask SampleAsTaskAsync( { Throw.IfNull(requestParams); Throw.IfNull(taskMetadata); + ThrowIfDraftProtocol(nameof(SampleAsTaskAsync), "Task-augmented sampling via the legacy 'sampling/createMessage' method is removed under draft. Track https://github.com/modelcontextprotocol/csharp-sdk for the upcoming 'tasks/input_response' MRTR-task pattern."); ThrowIfSamplingUnsupported(); ThrowIfTasksUnsupportedForSampling(); @@ -127,6 +129,7 @@ public async Task SampleAsync( IEnumerable messages, ChatOptions? chatOptions = default, JsonSerializerOptions? serializerOptions = null, CancellationToken cancellationToken = default) { Throw.IfNull(messages); + ThrowIfDraftProtocol(nameof(SampleAsync), "Throw 'InputRequiredException' from your handler with 'InputRequest.ForSampling(...)' instead."); serializerOptions ??= McpJsonUtilities.DefaultOptions; @@ -254,6 +257,7 @@ public async Task SampleAsync( /// The client does not support sampling. public IChatClient AsSamplingChatClient(JsonSerializerOptions? serializerOptions = null) { + ThrowIfDraftProtocol(nameof(AsSamplingChatClient), "Throw 'InputRequiredException' from your handler with 'InputRequest.ForSampling(...)' instead."); ThrowIfSamplingUnsupported(); return new SamplingChatClient(this, serializerOptions ?? McpJsonUtilities.DefaultOptions); @@ -278,6 +282,7 @@ public ValueTask RequestRootsAsync( CancellationToken cancellationToken = default) { Throw.IfNull(requestParams); + ThrowIfDraftProtocol(nameof(RequestRootsAsync), "Throw 'InputRequiredException' from your handler with 'InputRequest.ForRootsList(...)' instead."); ThrowIfRootsUnsupported(); return SendRequestAsync( @@ -307,6 +312,7 @@ public async ValueTask ElicitAsync( CancellationToken cancellationToken = default) { Throw.IfNull(requestParams); + ThrowIfDraftProtocol(nameof(ElicitAsync), "Throw 'InputRequiredException' from your handler with 'InputRequest.ForElicitation(...)' instead."); ThrowIfElicitationUnsupported(requestParams); var result = await SendRequestWithTaskStatusTrackingAsync( @@ -342,6 +348,7 @@ public async ValueTask ElicitAsTaskAsync( { Throw.IfNull(requestParams); Throw.IfNull(taskMetadata); + ThrowIfDraftProtocol(nameof(ElicitAsTaskAsync), "Task-augmented elicitation via the legacy 'elicitation/create' method is removed under draft. Track https://github.com/modelcontextprotocol/csharp-sdk for the upcoming 'tasks/input_response' MRTR-task pattern."); ThrowIfElicitationUnsupported(requestParams); ThrowIfTasksUnsupportedForElicitation(); @@ -676,6 +683,7 @@ public async ValueTask> ElicitAsync( CancellationToken cancellationToken = default) { Throw.IfNullOrWhiteSpace(message); + ThrowIfDraftProtocol(nameof(ElicitAsync), "Throw 'InputRequiredException' from your handler with 'InputRequest.ForElicitation(...)' instead."); var serializerOptions = options?.JsonSerializerOptions ?? McpJsonUtilities.DefaultOptions; serializerOptions.MakeReadOnly(); @@ -838,6 +846,25 @@ private static bool TryValidateElicitationPrimitiveSchema(JsonElement schema, Ty return true; } + /// + /// SEP-2322 (MRTR) removes the server→client elicitation/create, + /// sampling/createMessage, and roots/list request methods from the + /// DRAFT-2026-v1 protocol revision. The only supported way to obtain client + /// input from a server handler under draft is to throw + /// and let the SDK + /// emit an on the wire. + /// + private void ThrowIfDraftProtocol(string memberName, string replacement) + { + if (NegotiatedProtocolVersion == McpSessionHandler.DraftProtocolVersion) + { + throw new InvalidOperationException( + $"'{memberName}' is not supported after negotiating MCP protocol version '{McpSessionHandler.DraftProtocolVersion}'. " + + $"The draft protocol removes the corresponding server-to-client request method per SEP-2322 (Multi Round-Trip Requests). " + + $"{replacement} See docs/concepts/mrtr/mrtr.md for details."); + } + } + private void ThrowIfSamplingUnsupported() { if (ClientCapabilities?.Sampling is null) diff --git a/src/ModelContextProtocol.Core/Server/McpServer.cs b/src/ModelContextProtocol.Core/Server/McpServer.cs index f2a78a561..444365361 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.cs @@ -83,40 +83,6 @@ protected McpServer() [Experimental(Experimentals.Mrtr_DiagnosticId, UrlFormat = Experimentals.Mrtr_Url)] public virtual bool IsMrtrSupported => false; - /// - /// Transitions the current tool execution from ephemeral MRTR mode to a background task. - /// - /// - /// - /// This method is only valid when called from a tool handler that has - /// set to - /// and the client provided task metadata in the tools/call request. - /// - /// - /// Before calling this method, - /// and use the ephemeral - /// MRTR mechanism (returning to the client). After calling this method, - /// the task is created and subsequent calls use the persistent workflow (task status - /// with tasks/result and tasks/input_response). - /// - /// - /// If the tool handler returns without calling this method, a normal (non-task) result is returned - /// to the client. - /// - /// - /// A token to cancel the task creation. - /// - /// The tool does not have enabled, or - /// the client did not provide task metadata, or this method was already called. - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - public virtual ValueTask CreateTaskAsync(CancellationToken cancellationToken = default) - { - throw new InvalidOperationException( - "CreateTaskAsync can only be called from a tool handler with DeferTaskCreation enabled " + - "when the client provides task metadata in the tools/call request."); - } - /// /// Runs the server, listening for and handling client requests. /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index e9653c3eb..3d7222ee5 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -2,7 +2,6 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ModelContextProtocol.Protocol; -using System.Collections.Concurrent; using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Nodes; @@ -29,13 +28,6 @@ internal sealed partial class McpServerImpl : McpServer private readonly McpSessionHandler _sessionHandler; private readonly SemaphoreSlim _disposeLock = new(1, 1); private readonly McpTaskCancellationTokenProvider? _taskCancellationTokenProvider; - private readonly ConcurrentDictionary _mrtrContinuations = new(); - private readonly ConcurrentDictionary _mrtrContextsByRequestId = new(); - - // Track MRTR handler tasks using the same inFlightCount + TCS pattern as - // McpSessionHandler.ProcessMessagesCoreAsync. Starts at 1 for DisposeAsync itself. - private int _mrtrInFlightCount = 1; - private readonly TaskCompletionSource _allMrtrHandlersCompleted = new(TaskCreationOptions.RunContinuationsAsynchronously); private ClientCapabilities? _clientCapabilities; private Implementation? _clientInfo; @@ -101,9 +93,6 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact ConfigureCompletion(options); ConfigureExperimentalAndExtensions(options); - // Wrap MRTR-eligible handlers AFTER all handler registration is complete. - ConfigureMrtr(); - // Register any notification handlers that were provided. if (options.Handlers.NotificationHandlers is { } notificationHandlers) { @@ -222,35 +211,9 @@ public override async ValueTask DisposeAsync() _disposed = true; - // Dispose the session handler first — cancels message processing and waits for all - // in-flight request handlers (including retries in AwaitMrtrHandlerAsync) to complete. - // After this returns, no new requests can be processed and no new MRTR continuations - // can be created, so _mrtrContinuations is effectively frozen. _taskCancellationTokenProvider?.Dispose(); _disposables.ForEach(d => d()); await _sessionHandler.DisposeAsync().ConfigureAwait(false); - - // Cancel all orphaned MRTR handlers still suspended in continuations (waiting for - // retries that will never arrive now that the session handler is disposed). - int cancelledCount = _mrtrContinuations.Count; - foreach (var continuation in _mrtrContinuations.Values) - { - continuation.CancelHandler(); - } - - if (cancelledCount > 0) - { - MrtrContinuationsCancelled(cancelledCount); - } - - // Wait for all MRTR handler tasks to complete using the same inFlightCount + TCS - // pattern as McpSessionHandler.ProcessMessagesCoreAsync. The count started at 1 - // (for DisposeAsync itself); decrementing it here triggers the drain if handlers - // are still in flight. ObserveHandlerCompletionAsync decrements for each handler. - if (Interlocked.Decrement(ref _mrtrInFlightCount) != 0) - { - await _allMrtrHandlersCompleted.Task.ConfigureAwait(false); - } } private void ConfigureInitialize(McpServerOptions options) @@ -764,32 +727,6 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) McpErrorCode.InvalidParams); } - // When DeferTaskCreation is enabled, run the handler through the normal - // MRTR-wrapped path with deferred task context, allowing ephemeral MRTR - // exchanges before the tool calls CreateTaskAsync(). - if (tool.DeferTaskCreation) - { - // Attach deferred task info to the MrtrContext so CreateTaskAsync() - // and AwaitMrtrHandlerAsync can use it. The MrtrContext was already - // created by WrapHandlerWithMrtr and set on the per-request server. - if (request.Server is DestinationBoundMcpServer destinationServer && - destinationServer.ActiveMrtrContext is { } mrtrContext) - { - mrtrContext.DeferredTask = new DeferredTaskInfo - { - TaskMetadata = taskMetadata, - OriginalRequestId = request.JsonRpcRequest.Id, - OriginalRequest = request.JsonRpcRequest, - TaskStore = taskStore!, - SendNotifications = sendNotifications, - }; - } - - // Execute normally — the MRTR wrapper (WrapHandlerWithMrtr) will handle - // racing between handler completion, MRTR exchanges, and task creation. - return await tool.InvokeAsync(request, cancellationToken).ConfigureAwait(false); - } - // Task augmentation requested with immediate creation return await ExecuteToolAsTaskAsync(tool, request, taskMetadata, taskStore, sendNotifications, cancellationToken).ConfigureAwait(false); } @@ -1092,21 +1029,8 @@ async ValueTask InvokeScopedAsync( } } - /// - /// Creates a per-request and attaches any pending - /// MRTR context that was stored by . - /// - private DestinationBoundMcpServer CreateDestinationBoundServer(JsonRpcRequest jsonRpcRequest) - { - var server = new DestinationBoundMcpServer(this, jsonRpcRequest.Context?.RelatedTransport); - - if (_mrtrContextsByRequestId.TryRemove(jsonRpcRequest.Id, out var mrtrContext)) - { - server.ActiveMrtrContext = mrtrContext; - } - - return server; - } + private DestinationBoundMcpServer CreateDestinationBoundServer(JsonRpcRequest jsonRpcRequest) => + new(this, jsonRpcRequest.Context?.RelatedTransport); private void SetHandler( string method, @@ -1118,6 +1042,13 @@ private void SetHandler( (request, jsonRpcRequest, cancellationToken) => InvokeHandlerAsync(handler, request, jsonRpcRequest, cancellationToken), requestTypeInfo, responseTypeInfo); + + if (method == RequestMethods.ToolsCall) + { + var originalHandler = _requestHandlers[method]; + _requestHandlers[method] = (request, cancellationToken) => + InvokeWithInputRequiredResultHandlingAsync(originalHandler, request, cancellationToken); + } } private static McpRequestHandler BuildFilterPipeline( @@ -1224,141 +1155,6 @@ internal bool IsLowLevelMrtrAvailable() => ClientSupportsMrtr() || _sessionTransport is not StreamableHttpServerTransport { Stateless: true }; - /// - /// Wraps MRTR-eligible request handlers so that when a handler calls ElicitAsync/SampleAsync, - /// an InputRequiredResult is returned early and the handler is suspended until the retry arrives. - /// - private void ConfigureMrtr() - { - // Wrap all methods that may trigger MRTR (server calling ElicitAsync/SampleAsync/RequestRootsAsync - // during handler execution). These methods may produce InputRequiredResult if the handler needs input. - WrapHandlerWithMrtr(RequestMethods.ToolsCall); - WrapHandlerWithMrtr(RequestMethods.PromptsGet); - WrapHandlerWithMrtr(RequestMethods.ResourcesRead); - } - - /// - /// Replaces an existing request handler entry with an MRTR-aware wrapper that supports - /// handler suspension and InputRequiredResult responses. - /// - private void WrapHandlerWithMrtr(string method) - { - if (!_requestHandlers.TryGetValue(method, out var originalHandler)) - { - return; - } - - _requestHandlers[method] = async (request, cancellationToken) => - { - // In stateless mode, each request creates a new server instance that never saw the - // initialize handshake, so _negotiatedProtocolVersion is null. Pick it up from the - // Mcp-Protocol-Version header that the transport layer flowed via JsonRpcMessageContext. - if (_negotiatedProtocolVersion is null && - request.Context?.ProtocolVersion is { } headerProtocolVersion) - { - _negotiatedProtocolVersion = headerProtocolVersion; - } - - // Check for MRTR retry: if requestState is present, look up the continuation. - if (request.Params is JsonObject paramsObj && - paramsObj.TryGetPropertyValue("requestState", out var requestStateNode) && - requestStateNode?.GetValueKind() == JsonValueKind.String && - requestStateNode.GetValue() is { } requestState) - { - if (_mrtrContinuations.TryRemove(requestState, out var existingContinuation)) - { - // High-level MRTR retry: resume the suspended handler with client responses. - IDictionary? inputResponses = null; - if (paramsObj.TryGetPropertyValue("inputResponses", out var responsesNode) && responsesNode is not null) - { - inputResponses = JsonSerializer.Deserialize(responsesNode, McpJsonUtilities.JsonContext.Default.IDictionaryStringInputResponse); - } - - var exchange = existingContinuation.PendingExchange!; - var nextExchangeTask = existingContinuation.MrtrContext.ResetForNextExchange(exchange); - - if (inputResponses is not null && - inputResponses.TryGetValue(exchange.Key, out var response)) - { - if (!exchange.ResponseTcs.TrySetResult(response)) - { - throw new McpProtocolException( - $"MRTR exchange '{exchange.Key}' was already completed (possibly cancelled).", - McpErrorCode.InternalError); - } - } - else - { - if (!exchange.ResponseTcs.TrySetException( - new McpProtocolException($"Missing input response for key '{exchange.Key}'.", McpErrorCode.InvalidParams))) - { - throw new McpProtocolException( - $"MRTR exchange '{exchange.Key}' was already completed (possibly cancelled).", - McpErrorCode.InternalError); - } - } - - return await AwaitMrtrHandlerAsync( - existingContinuation.HandlerTask, existingContinuation, nextExchangeTask, cancellationToken).ConfigureAwait(false); - } - - // Low-level MRTR retry or invalid requestState: no continuation found. - // Fall through to the standard MRTR-aware invocation path below. The retry data - // (inputResponses, requestState) is already in the deserialized request params - // for low-level handlers to access, and the MrtrContext will be set up for - // high-level handlers that call ElicitAsync/SampleAsync. - } - - // Implicit MRTR (handler suspension across ElicitAsync/SampleAsync) emits - // InputRequiredResult on the wire, which only DRAFT-2026-v1 clients understand, - // and requires the same server instance to handle the retry (stateful session). - // For all other cases — legacy clients, stateless sessions — fall through to the - // exception-based path, which transparently resolves InputRequiredException via - // legacy JSON-RPC requests when the client doesn't speak MRTR. - if (!ClientSupportsMrtr() || !IsStatefulSession()) - { - return await InvokeWithInputRequiredResultHandlingAsync(originalHandler, request, cancellationToken).ConfigureAwait(false); - } - - // Start a new MRTR-aware handler invocation. - var mrtrContext = new MrtrContext(); - - // Create a long-lived CTS for the handler that survives across retries. - // The original request's combinedCts will be disposed when this lambda returns, - // breaking the cancellation chain. This CTS keeps the handler cancellable. - // Like Kestrel's HttpContext.RequestAborted, the CTS is never disposed — Cancel() - // is thread-safe with itself, and not disposing avoids deadlock risks from - // calling Cancel/Dispose inside locks or Interlocked guards. - var handlerCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - - // Store the MrtrContext so CreateDestinationBoundServer can pick it up and set it - // on the per-request DestinationBoundMcpServer. This is picked up synchronously - // before any await, so the finally cleanup is safe. - _mrtrContextsByRequestId[request.Id] = mrtrContext; - Task handlerTask; - try - { - handlerTask = originalHandler(request, handlerCts.Token); - } - finally - { - _mrtrContextsByRequestId.TryRemove(request.Id, out _); - } - - // Wrap handler state into a continuation for lifecycle management across retries. - var continuation = new MrtrContinuation(handlerCts, handlerTask, mrtrContext); - - // Track the handler task for lifecycle management. The observer logs unhandled - // exceptions and decrements _mrtrInFlightCount when the handler completes, - // mirroring how McpSessionHandler tracks in-flight handlers. - Interlocked.Increment(ref _mrtrInFlightCount); - _ = ObserveHandlerCompletionAsync(handlerTask); - - return await AwaitMrtrHandlerAsync( - handlerTask, continuation, mrtrContext.InitialExchangeTask, cancellationToken).ConfigureAwait(false); - }; - } - /// /// Invokes a handler and catches to convert it to an /// JSON response. When MRTR is negotiated or the server is stateless, @@ -1373,6 +1169,14 @@ private void WrapHandlerWithMrtr(string method) { const int MaxRetries = 10; + // In stateless mode, pick up the negotiated draft protocol version from the + // transport-provided request context because there is no long-lived initialize handshake state. + if (_negotiatedProtocolVersion is null && + request.Context?.ProtocolVersion is { } headerProtocolVersion) + { + _negotiatedProtocolVersion = headerProtocolVersion; + } + for (int retry = 0; ; retry++) { try @@ -1391,7 +1195,8 @@ private void WrapHandlerWithMrtr(string method) // In stateless mode without MRTR, the server can't resolve input requests via // JSON-RPC (no persistent session for server-to-client requests), and the client // won't recognize the InputRequiredResult. This is the one unsupported configuration. - if (_sessionTransport is StreamableHttpServerTransport { Stateless: true }) + // TODO(stateless-draft): When DRAFT-2026-v1 becomes stateless-only, the IsStatefulSession() gate collapses — the stateful path will only matter for legacy clients on the current protocol. + if (!IsStatefulSession()) { throw new McpException( "A tool handler returned an incomplete result, but the server is stateless and the client does not support MRTR. " + @@ -1470,276 +1275,9 @@ private async Task ResolveInputRequestAsync(InputRequest inputReq } } - /// - /// Awaits the outcome of an MRTR-enabled handler invocation. - /// If the handler completes, returns its result. If an exchange arrives (handler needs input), - /// builds and returns an InputRequiredResult and stores the continuation for future retries. - /// If the handler throws , the result is returned directly - /// without storing a continuation (low-level MRTR path). - /// When deferred task creation is enabled, also races against the task creation signal. - /// - private async Task AwaitMrtrHandlerAsync( - Task handlerTask, - MrtrContinuation continuation, - Task exchangeTask, - CancellationToken cancellationToken) - { - // Link the current request's cancellation to the handler's long-lived CTS. - // On the initial call this is redundant (handlerCts is already linked to cancellationToken) - // but on retries this is critical: the retry's combinedCts cancellation must flow to the handler. - // This is how notifications/cancelled for the retry's request ID reaches the handler. - using var registration = cancellationToken.Register( - static state => ((MrtrContinuation)state!).CancelHandler(), continuation); - - var deferredTask = continuation.MrtrContext.DeferredTask; - - // Race handler against MRTR exchange and optionally the deferred task creation signal. - Task completedTask; - if (deferredTask is not null) - { - completedTask = await Task.WhenAny(handlerTask, exchangeTask, deferredTask.SignalTask).ConfigureAwait(false); - } - else - { - completedTask = await Task.WhenAny(handlerTask, exchangeTask).ConfigureAwait(false); - } - - if (completedTask == handlerTask) - { - // Handler completed - return its result, propagate its exception, or handle InputRequiredException. - return await AwaitHandlerWithInputRequiredResultHandlingAsync(handlerTask).ConfigureAwait(false); - } - - if (deferredTask is not null && completedTask == deferredTask.SignalTask) - { - // Handler called CreateTaskAsync() — transition to task mode. - return await HandleDeferredTaskCreationAsync(handlerTask, continuation, deferredTask, cancellationToken).ConfigureAwait(false); - } - - // Exchange arrived - handler needs input from the client (high-level MRTR path). - var exchange = await exchangeTask.ConfigureAwait(false); - - var correlationId = Guid.NewGuid().ToString("N"); - var InputRequiredResult = new InputRequiredResult - { - InputRequests = new Dictionary { [exchange.Key] = exchange.InputRequest }, - RequestState = correlationId, - }; - - // Store the continuation so the retry can resume the handler. - continuation.PendingExchange = exchange; - _mrtrContinuations[correlationId] = continuation; - - return SerializeInputRequiredResult(InputRequiredResult); - } - - /// - /// Fire-and-forget observer for an MRTR handler task. Logs unhandled exceptions at Error - /// level and decrements when the handler completes, following - /// the same in-flight tracking pattern as . - /// - private async Task ObserveHandlerCompletionAsync(Task handlerTask) - { - try - { - await handlerTask.ConfigureAwait(false); - } - catch (OperationCanceledException) - { - // Handler cancelled — expected lifecycle event (disposal, client cancel, session shutdown). - } - catch (InputRequiredException) - { - // Low-level MRTR: handler explicitly signaling an InputRequiredResult. Not an error. - } - catch (Exception ex) - { - MrtrHandlerError(ex); - } - finally - { - if (Interlocked.Decrement(ref _mrtrInFlightCount) == 0) - { - _allMrtrHandlersCompleted.TrySetResult(true); - } - } - } - - /// - /// Awaits a handler task, catching to convert it to an - /// JSON response without storing a continuation. - /// - private static async Task AwaitHandlerWithInputRequiredResultHandlingAsync(Task handlerTask) - { - try - { - return await handlerTask.ConfigureAwait(false); - } - catch (InputRequiredException ex) - { - return SerializeInputRequiredResult(ex.Result); - } - } - private static JsonNode? SerializeInputRequiredResult(InputRequiredResult InputRequiredResult) => JsonSerializer.SerializeToNode(InputRequiredResult, McpJsonUtilities.JsonContext.Default.InputRequiredResult); - /// - /// Handles the transition from ephemeral MRTR to task-based execution when the handler - /// calls . - /// Creates the task, acknowledges the handler, re-links the handler CTS to the task's - /// cancellation token, and returns CreateTaskResult to the client. - /// - private async Task HandleDeferredTaskCreationAsync( - Task handlerTask, - MrtrContinuation continuation, - DeferredTaskInfo deferredTask, - CancellationToken cancellationToken) - { - var taskStore = deferredTask.TaskStore; - var sendNotifications = deferredTask.SendNotifications; - - Protocol.McpTask mcpTask; - CancellationToken taskCancellationToken; - try - { - // Create the task in the task store. - mcpTask = await taskStore.CreateTaskAsync( - deferredTask.TaskMetadata, - deferredTask.OriginalRequestId, - deferredTask.OriginalRequest, - SessionId, - cancellationToken).ConfigureAwait(false); - - // Register the task for TTL-based cancellation. - taskCancellationToken = _taskCancellationTokenProvider!.RequestToken(mcpTask.TaskId, mcpTask.TimeToLive); - - // Re-link the handler's CTS to the task's cancellation token so handler - // cancellation tracks the task lifecycle (TTL expiration, explicit cancel) - // instead of the original request. - taskCancellationToken.Register( - static state => ((MrtrContinuation)state!).CancelHandler(), continuation); - - // Update task status to working. - var workingTask = await taskStore.UpdateTaskStatusAsync( - mcpTask.TaskId, - McpTaskStatus.Working, - null, - SessionId, - CancellationToken.None).ConfigureAwait(false); - - if (sendNotifications) - { - _ = NotifyTaskStatusAsync(workingTask, CancellationToken.None); - } - } - catch (Exception ex) - { - // If task creation fails, propagate the exception to the handler - // so CreateTaskAsync() throws instead of blocking forever. - deferredTask.AcknowledgeFailure(ex); - throw; - } - - // Acknowledge the handler so CreateTaskAsync() returns and the handler continues. - deferredTask.AcknowledgeTaskCreation(new DeferredTaskCreationResult - { - TaskId = mcpTask.TaskId, - SessionId = SessionId, - TaskStore = taskStore, - SendNotifications = sendNotifications, - NotifyTaskStatusFunc = NotifyTaskStatusAsync, - TaskCancellationToken = taskCancellationToken, - }); - - // Track the handler task in the background. The handler is already tracked by - // ObserveHandlerCompletionAsync (via _mrtrInFlightCount), so no additional - // in-flight tracking is needed here — just status updates. - _ = TrackDeferredHandlerTaskAsync(handlerTask, mcpTask, taskStore, sendNotifications); - - // Return CreateTaskResult to the client. - var createTaskResult = new CallToolResult { Task = mcpTask }; - return JsonSerializer.SerializeToNode(createTaskResult, McpJsonUtilities.JsonContext.Default.CallToolResult); - } - - /// - /// Tracks a deferred handler task after task creation, updating task status and storing results. - /// The handler task is already tracked by for - /// in-flight counting and error logging. - /// - private async Task TrackDeferredHandlerTaskAsync( - Task handlerTask, - Protocol.McpTask mcpTask, - IMcpTaskStore taskStore, - bool sendNotifications) - { - try - { - var resultNode = await handlerTask.ConfigureAwait(false); - - CallToolResult? result = null; - if (resultNode is not null) - { - result = JsonSerializer.Deserialize(resultNode, McpJsonUtilities.JsonContext.Default.CallToolResult); - } - - var finalStatus = result?.IsError is true ? McpTaskStatus.Failed : McpTaskStatus.Completed; - var resultElement = result is not null - ? JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.CallToolResult) - : default; - - var finalTask = await taskStore.StoreTaskResultAsync( - mcpTask.TaskId, - finalStatus, - resultElement, - SessionId, - CancellationToken.None).ConfigureAwait(false); - - if (sendNotifications) - { - _ = NotifyTaskStatusAsync(finalTask, CancellationToken.None); - } - } - catch (OperationCanceledException) - { - // After task creation, any handler cancellation is legitimate — - // task TTL expiration, explicit tasks/cancel, or session disposal. - } - catch (Exception ex) - { - // Error logging is already handled by ObserveHandlerCompletionAsync. - var errorResult = new CallToolResult - { - IsError = true, - Content = [new TextContentBlock { Text = $"Task execution failed: {ex.Message}" }], - }; - - try - { - var errorResultElement = JsonSerializer.SerializeToElement(errorResult, McpJsonUtilities.JsonContext.Default.CallToolResult); - var failedTask = await taskStore.StoreTaskResultAsync( - mcpTask.TaskId, - McpTaskStatus.Failed, - errorResultElement, - SessionId, - CancellationToken.None).ConfigureAwait(false); - - if (sendNotifications) - { - _ = NotifyTaskStatusAsync(failedTask, CancellationToken.None); - } - } - catch - { - // If we can't store the error result, the task will remain in "working" status. - } - } - finally - { - _taskCancellationTokenProvider!.Complete(mcpTask.TaskId); - } - } - [LoggerMessage(Level = LogLevel.Error, Message = "\"{ToolName}\" threw an unhandled exception.")] private partial void ToolCallError(string toolName, Exception exception); @@ -1758,12 +1296,6 @@ private async Task TrackDeferredHandlerTaskAsync( [LoggerMessage(Level = LogLevel.Information, Message = "ReadResource \"{ResourceUri}\" completed.")] private partial void ReadResourceCompleted(string resourceUri); - [LoggerMessage(Level = LogLevel.Debug, Message = "Cancelled {Count} pending MRTR continuation(s) during session disposal.")] - private partial void MrtrContinuationsCancelled(int count); - - [LoggerMessage(Level = LogLevel.Debug, Message = "An MRTR handler threw an unhandled exception.")] - private partial void MrtrHandlerError(Exception exception); - /// /// Executes a tool call as a task and returns a CallToolTaskResult immediately. /// @@ -1819,13 +1351,6 @@ private async ValueTask ExecuteToolAsTaskAsync( NotifyTaskStatusFunc = NotifyTaskStatusAsync }; - // MRTR doesn't apply here because the task hasn't opted into deferred creation, - // and the original request was already answered with CreateTaskResult. - if (request.Server is DestinationBoundMcpServer destinationServer) - { - destinationServer.ActiveMrtrContext = null; - } - try { // Update task status to working diff --git a/src/ModelContextProtocol.Core/Server/McpServerTool.cs b/src/ModelContextProtocol.Core/Server/McpServerTool.cs index cf71daa87..8144311e0 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerTool.cs @@ -158,13 +158,6 @@ protected McpServerTool() /// Gets the protocol type for this instance. public abstract Tool ProtocolTool { get; } - /// - /// Gets a value indicating whether the tool defers task creation, allowing - /// ephemeral MRTR exchanges before committing to a background task. - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - public virtual bool DeferTaskCreation => false; - /// /// Gets the metadata for this tool instance. /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs index a02fc1cfc..4caf07197 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs @@ -158,7 +158,6 @@ public sealed class McpServerToolAttribute : Attribute internal bool? _openWorld; internal bool? _readOnly; internal ToolTaskSupport? _taskSupport; - internal bool? _deferTaskCreation; /// /// Initializes a new instance of the class. @@ -327,27 +326,4 @@ public ToolTaskSupport TaskSupport set => _taskSupport = value; } - /// - /// Gets or sets a value indicating whether the tool defers task creation, allowing - /// ephemeral MRTR exchanges before committing to a background task via - /// . - /// - /// - /// if the tool handler can perform MRTR interactions before - /// deciding whether to create a task; if a task is created - /// immediately when the client provides task metadata. - /// The default is . - /// - /// - /// When enabled and the client provides task metadata, the handler runs through the - /// normal MRTR-wrapped path. The handler may call - /// to transition to a - /// background task, or it may return a normal result without creating a task. - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - public bool DeferTaskCreation - { - get => _deferTaskCreation ?? false; - set => _deferTaskCreation = value; - } -} +} \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs index e805ee57e..88d718d13 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs @@ -214,20 +214,6 @@ public sealed class McpServerToolCreateOptions [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] public ToolExecution? Execution { get; set; } - /// - /// Gets or sets a value indicating whether the tool defers task creation, allowing - /// ephemeral MRTR exchanges before committing to a background task via - /// . - /// - /// - /// When and the client provides task metadata, the handler runs through - /// the normal MRTR-wrapped path. The handler may call - /// to transition to a background task, - /// or it may return a normal result without creating a task. - /// - [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] - public bool DeferTaskCreation { get; set; } - /// /// Creates a shallow clone of the current instance. /// @@ -250,6 +236,5 @@ internal McpServerToolCreateOptions Clone() => Icons = Icons, Meta = Meta, Execution = Execution, - DeferTaskCreation = DeferTaskCreation, }; } diff --git a/src/ModelContextProtocol.Core/Server/MrtrContext.cs b/src/ModelContextProtocol.Core/Server/MrtrContext.cs deleted file mode 100644 index 38ad349ff..000000000 --- a/src/ModelContextProtocol.Core/Server/MrtrContext.cs +++ /dev/null @@ -1,85 +0,0 @@ -using ModelContextProtocol.Protocol; - -namespace ModelContextProtocol.Server; - -/// -/// Manages the MRTR (Multi Round-Trip Request) coordination between a handler and the pipeline. -/// When a handler calls or -/// , -/// the handler sets the exchange TCS and suspends on a response TCS. The pipeline detects the exchange -/// via or the task returned by , -/// sends an , and later completes the response TCS when the retry arrives. -/// -internal sealed class MrtrContext -{ - private TaskCompletionSource _exchangeTcs = new(TaskCreationOptions.RunContinuationsAsynchronously); - private int _nextInputRequestId; - - /// - /// Gets the task for the initial MRTR exchange. Set once in the constructor and never changes. - /// For subsequent exchanges after a retry, use the task returned by . - /// - public Task InitialExchangeTask { get; } - - public MrtrContext() - { - InitialExchangeTask = _exchangeTcs.Task; - } - - /// - /// Gets or sets the deferred task creation info, if the tool opted into deferred task creation - /// and the client provided task metadata. When set, - /// uses this to signal the framework. - /// - public DeferredTaskInfo? DeferredTask { get; set; } - - /// - /// Prepares the context for the next round of exchange after a retry arrives. - /// Uses to atomically validate that - /// still references the TCS that produced , - /// ensuring concurrent calls reliably fail. - /// - /// The exchange from the previous round whose - /// response has been (or is about to be) completed. - /// A task that completes when the handler requests input via - /// . - /// The context state was modified concurrently. - public Task ResetForNextExchange(MrtrExchange previousExchange) - { - var newTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - if (Interlocked.CompareExchange(ref _exchangeTcs, newTcs, previousExchange.SourceTcs) != previousExchange.SourceTcs) - { - throw new InvalidOperationException("MrtrContext was modified concurrently."); - } - - return newTcs.Task; - } - - /// - /// Called by - /// or - /// to request input from the client via the MRTR mechanism. - /// - /// The input request describing what the server needs. - /// A token to cancel the wait for input. - /// The client's response to the input request. - /// A concurrent server-to-client request is already pending. - public async Task RequestInputAsync(InputRequest inputRequest, CancellationToken cancellationToken) - { - var key = $"input_{Interlocked.Increment(ref _nextInputRequestId)}"; - var tcs = _exchangeTcs; - 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 - // are not supported. - if (!tcs.TrySetResult(exchange)) - { - throw new InvalidOperationException( - "Concurrent server-to-client requests are not supported. " + - "Await each ElicitAsync, SampleAsync, or RequestRootsAsync call before making another."); - } - - return await exchange.ResponseTcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); - } -} diff --git a/src/ModelContextProtocol.Core/Server/MrtrContinuation.cs b/src/ModelContextProtocol.Core/Server/MrtrContinuation.cs deleted file mode 100644 index 0a8a6e719..000000000 --- a/src/ModelContextProtocol.Core/Server/MrtrContinuation.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Text.Json.Nodes; - -namespace ModelContextProtocol.Server; - -/// -/// Represents the lifecycle state for an MRTR handler invocation across retries. -/// Created when the handler starts and stored in _mrtrContinuations when -/// the handler suspends waiting for client input. -/// -internal sealed class MrtrContinuation -{ - private readonly CancellationTokenSource _handlerCts; - - public MrtrContinuation(CancellationTokenSource handlerCts, Task handlerTask, MrtrContext mrtrContext) - { - _handlerCts = handlerCts; - HandlerTask = handlerTask; - MrtrContext = mrtrContext; - } - - /// - /// Gets a token that cancels when the handler should be aborted. - /// Passed to the handler at creation and remains valid across retries. - /// - public CancellationToken HandlerToken => _handlerCts.Token; - - /// - /// The handler task that is suspended awaiting input. - /// - public Task HandlerTask { get; } - - /// - /// The MRTR context for the handler's async flow. - /// - public MrtrContext MrtrContext { get; } - - /// - /// The exchange that is awaiting a response from the client. - /// Set each time the handler suspends on a new exchange. - /// - public MrtrExchange? PendingExchange { get; set; } - - /// - /// Cancels the handler. Safe to call multiple times and concurrently — - /// is thread-safe with itself. - /// The CTS is intentionally never disposed to avoid deadlock risks from - /// calling Cancel/Dispose inside synchronization primitives. - /// - public void CancelHandler() => _handlerCts.Cancel(); -} diff --git a/src/ModelContextProtocol.Core/Server/MrtrExchange.cs b/src/ModelContextProtocol.Core/Server/MrtrExchange.cs deleted file mode 100644 index cf0a86af4..000000000 --- a/src/ModelContextProtocol.Core/Server/MrtrExchange.cs +++ /dev/null @@ -1,41 +0,0 @@ -using ModelContextProtocol.Protocol; - -namespace ModelContextProtocol.Server; - -/// -/// Represents a single exchange between the handler and the pipeline during an MRTR flow. -/// The handler creates the exchange and awaits the response TCS. The pipeline reads the exchange, -/// sends the to the client, and completes the TCS when the response arrives. -/// -internal sealed class MrtrExchange -{ - public MrtrExchange(string key, InputRequest inputRequest, TaskCompletionSource sourceTcs) - { - Key = key; - InputRequest = inputRequest; - SourceTcs = sourceTcs; - ResponseTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - } - - /// - /// The unique key identifying this exchange within the MRTR round trip. - /// - public string Key { get; } - - /// - /// The input request that needs to be fulfilled by the client. - /// - public InputRequest InputRequest { get; } - - /// - /// The that this exchange was set as the result of. - /// Used by on retry to validate - /// the expected state via . - /// - internal TaskCompletionSource SourceTcs { get; } - - /// - /// The TCS that will be completed with the client's response. - /// - public TaskCompletionSource ResponseTcs { get; } -} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs index 29e4094cc..f024777f0 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs @@ -152,7 +152,6 @@ private static async Task MrtrMixed(McpServer server, RequestContext MrtrParallelAwait(McpServer server, Cancellati RequestedSchema = new() }, ct); - // Start the second await — with MRTR, this throws InvalidOperationException - // because MrtrContext only supports one pending exchange at a time. + // Start the second await. This path is only exercised for legacy clients now + // that draft clients must use InputRequiredException instead of await-style requests. try { var sampleTask = server.SampleAsync(new CreateMessageRequestParams @@ -258,12 +257,10 @@ private static async Task MrtrParallelAwait(McpServer server, Cancellati } [Theory] - [InlineData(true)] [InlineData(false)] public async Task Mrtr_ParallelAwaits(bool experimentalClient) { - // Parallel awaits work with regular JSON-RPC but fail with MRTR because - // MrtrContext only supports one exchange at a time (TrySetResult gate). + // Parallel awaits work with regular JSON-RPC for legacy clients. Assert.SkipWhen(Stateless, "Await-style API requires handler suspension (stateful only)."); var messageTracker = ConfigureServer(MrtrParallelAwait); @@ -278,8 +275,7 @@ public async Task Mrtr_ParallelAwaits(bool experimentalClient) if (experimentalClient) { - // MRTR active. Parallel awaits hit the MrtrContext concurrency gate and the second - // call throws InvalidOperationException, which the tool catches and returns as text. + // Draft clients must use InputRequiredException instead of await-style requests. Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("mrtr-parallel-await", diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs index 3400566fb..32cf9508f 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs @@ -124,95 +124,6 @@ public async Task RetryWithInvalidRequestState_ReturnsJsonRpcError() $"Expected JsonRpcResponse or JsonRpcError, got {message?.GetType().Name}"); } - [Fact] - public async Task SessionDelete_CancelsPendingMrtrContinuation() - { - await StartAsync(); - await InitializeWithMrtrAsync(); - - // 1. Call a tool that suspends at ElicitAsync (high-level MRTR path). - var response = await PostJsonRpcAsync(CallTool("elicit-tool", """{"message":"Please confirm"}""")); - var rpcResponse = await AssertSingleSseResponseAsync(response); - - // Verify we got an InputRequiredResult (handler is now suspended, continuation stored). - var resultObj = Assert.IsType(rpcResponse.Result); - Assert.Equal("input_required", resultObj["resultType"]?.GetValue()); - var requestState = resultObj["requestState"]!.GetValue(); - Assert.False(string.IsNullOrEmpty(requestState)); - - // 2. DELETE the session while the handler is suspended. - using var deleteResponse = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode); - - // Poll for the async cancellation to propagate through the handler task. - // Under thread pool starvation, this can take significantly longer than 100ms. - var deadline = DateTime.UtcNow.AddSeconds(30); - while (true) - { - if (MockLoggerProvider.LogMessages.Any(m => m.Message.Contains("pending MRTR continuation")) - || DateTime.UtcNow >= deadline) - { - break; - } - - await Task.Delay(100, TestContext.Current.CancellationToken); - } - - // 3. Verify that the MRTR cancellation was logged at Debug level. - var mrtrCancelledLog = MockLoggerProvider.LogMessages - .Where(m => m.Message.Contains("pending MRTR continuation")) - .ToList(); - Assert.Single(mrtrCancelledLog); - Assert.Equal(LogLevel.Debug, mrtrCancelledLog[0].LogLevel); - Assert.Contains("1", mrtrCancelledLog[0].Message); - - // 4. Verify no error-level log was emitted for the cancellation. - // The handler's OperationCanceledException should be silently observed, not logged as an error. - var errorLogs = MockLoggerProvider.LogMessages - .Where(m => m.LogLevel >= LogLevel.Error && m.Message.Contains("elicit")) - .ToList(); - Assert.Empty(errorLogs); - } - - [Fact] - public async Task SessionDelete_RetryAfterDelete_ReturnsSessionNotFound() - { - await StartAsync(); - await InitializeWithMrtrAsync(); - - // 1. Call a tool that suspends at ElicitAsync. - var response = await PostJsonRpcAsync(CallTool("elicit-tool", """{"message":"Please confirm"}""")); - var rpcResponse = await AssertSingleSseResponseAsync(response); - - var resultObj = Assert.IsType(rpcResponse.Result); - var requestState = resultObj["requestState"]!.GetValue(); - var inputRequests = resultObj["inputRequests"]!.AsObject(); - var inputKey = inputRequests.First().Key; - - // 2. DELETE the session. - using var deleteResponse = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode); - - // 3. Attempt to retry with the old requestState — session is gone. - var inputResponse = InputResponse.FromElicitResult(new ElicitResult { Action = "accept" }); - var retryParams = new JsonObject - { - ["name"] = "elicit-tool", - ["arguments"] = new JsonObject { ["message"] = "Please confirm" }, - ["requestState"] = requestState, - ["inputResponses"] = new JsonObject - { - [inputKey] = JsonSerializer.SerializeToNode(inputResponse, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(InputResponse))) - }, - }; - - using var retryResponse = await PostJsonRpcAsync(Request("tools/call", retryParams.ToJsonString())); - - // The session was deleted, so we should get a 404 with a JSON-RPC error. - Assert.Equal(HttpStatusCode.NotFound, retryResponse.StatusCode); - Assert.Equal("application/json", retryResponse.Content.Headers.ContentType?.MediaType); - } - // --- Helpers --- private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json"); diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientDeferredTaskCreationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientDeferredTaskCreationTests.cs deleted file mode 100644 index 911c8ac83..000000000 --- a/tests/ModelContextProtocol.Tests/Client/McpClientDeferredTaskCreationTests.cs +++ /dev/null @@ -1,334 +0,0 @@ -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; -using ModelContextProtocol.Client; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; -using ModelContextProtocol.Tests.Utils; -using System.ComponentModel; -using System.Text.Json; - -namespace ModelContextProtocol.Tests.Client; - -/// -/// Tests for deferred task creation, where a tool performs ephemeral MRTR exchanges -/// before committing to a background task via . -/// -public class McpClientDeferredTaskCreationTests : ClientServerTestBase -{ - private readonly TaskCompletionSource _toolAfterTaskCreation = new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly InMemoryMcpTaskStore _taskStore = new(); - - public McpClientDeferredTaskCreationTests(ITestOutputHelper testOutputHelper) - : base(testOutputHelper, startServer: false) - { - } - - protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) - { - services.AddSingleton(_taskStore); - services.Configure(options => - { - options.TaskStore = _taskStore; - options.ProtocolVersion = "DRAFT-2026-v1"; - }); - - mcpServerBuilder.WithTools() - .WithTools([ - // Tool that elicits before creating a task, then does work in background. - McpServerTool.Create( - async (string vmName, McpServer server, CancellationToken ct) => - { - // Phase 1: Ephemeral MRTR — confirm with user before starting expensive work. - var confirmation = await server.ElicitAsync(new ElicitRequestParams - { - Message = $"Provision VM '{vmName}'? This will incur costs.", - RequestedSchema = new() - }, ct); - - if (confirmation.Action != "confirm") - { - return "Cancelled by user."; - } - - // Phase 2: Transition to task. - await server.CreateTaskAsync(ct); - _toolAfterTaskCreation.TrySetResult(true); - - // Phase 3: Background work (simulated). - await Task.Delay(50, ct); - return $"VM '{vmName}' provisioned successfully."; - }, - new McpServerToolCreateOptions - { - Name = "provision-vm", - Description = "Provisions a VM with user confirmation", - DeferTaskCreation = true, - Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional }, - }), - - // Tool that does MRTR but returns without creating a task. - McpServerTool.Create( - async (string question, McpServer server, CancellationToken ct) => - { - var result = await server.ElicitAsync(new ElicitRequestParams - { - Message = question, - RequestedSchema = new() - }, ct); - - return $"Answer: {result.Action}"; - }, - new McpServerToolCreateOptions - { - Name = "ask-question", - Description = "Asks a question and returns the answer without creating a task", - DeferTaskCreation = true, - Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional }, - }), - - // Tool that does NOT have DeferTaskCreation — existing behavior. - McpServerTool.Create( - async (string input, CancellationToken ct) => - { - await Task.Delay(50, ct); - return $"Processed: {input}"; - }, - new McpServerToolCreateOptions - { - Name = "immediate-task-tool", - Description = "A task tool with immediate task creation (default)", - Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional }, - }), - - // Tool that does multiple MRTR rounds, then creates a task. - McpServerTool.Create( - async (McpServer server, CancellationToken ct) => - { - // Round 1: Ask for name. - var nameResult = await server.ElicitAsync(new ElicitRequestParams - { - Message = "What is your name?", - RequestedSchema = new() - }, ct); - - // Round 2: Ask for email. - var emailResult = await server.ElicitAsync(new ElicitRequestParams - { - Message = "What is your email?", - RequestedSchema = new() - }, ct); - - // Transition to task after gathering all input. - await server.CreateTaskAsync(ct); - - await Task.Delay(50, ct); - return $"Registered: {nameResult.Action}, {emailResult.Action}"; - }, - new McpServerToolCreateOptions - { - Name = "multi-round-then-task", - Description = "Does multiple MRTR rounds then creates a task", - DeferTaskCreation = true, - Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional }, - }), - ]); - } - - private static McpClientHandlers CreateElicitationHandlers() - { - return new McpClientHandlers - { - ElicitationHandler = (request, ct) => new ValueTask(new ElicitResult - { - Action = "confirm", - Content = new Dictionary() - }) - }; - } - - private async Task CallToolWithTaskMetadataAsync( - McpClient client, string toolName, Dictionary? arguments = null) - { - var requestParams = new CallToolRequestParams - { - Name = toolName, - Task = new McpTaskMetadata(), - }; - - if (arguments is not null) - { - requestParams.Arguments = arguments.ToDictionary( - kvp => kvp.Key, - kvp => kvp.Value is not null - ? JsonSerializer.SerializeToElement(kvp.Value, McpJsonUtilities.DefaultOptions) - : default); - } - - return await client.CallToolAsync(requestParams, TestContext.Current.CancellationToken); - } - - private McpClientOptions CreateClientOptions(McpClientHandlers? handlers = null) - { - return new McpClientOptions - { - ProtocolVersion = "DRAFT-2026-v1", - TaskStore = _taskStore, - Handlers = handlers ?? CreateElicitationHandlers() - }; - } - - private async Task WaitForTaskCompletionAsync(string taskId) - { - McpTask? taskStatus; - do - { - await Task.Delay(100, TestContext.Current.CancellationToken); - taskStatus = await _taskStore.GetTaskAsync(taskId, cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull(taskStatus); - } - while (taskStatus.Status is McpTaskStatus.Working or McpTaskStatus.InputRequired); - - return taskStatus; - } - - [Fact] - public async Task DeferredTaskCreation_ElicitThenCreateTask_ReturnsTaskResult() - { - StartServer(); - await using var client = await CreateMcpClientForServer(CreateClientOptions()); - - var result = await CallToolWithTaskMetadataAsync(client, "provision-vm", - new Dictionary { ["vmName"] = "test-vm" }); - - // The result should have a task (created after MRTR elicitation). - Assert.NotNull(result.Task); - Assert.NotEmpty(result.Task.TaskId); - - // Wait for the tool to finish in the background. - await _toolAfterTaskCreation.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); - var taskStatus = await WaitForTaskCompletionAsync(result.Task.TaskId); - Assert.Equal(McpTaskStatus.Completed, taskStatus.Status); - } - - [Fact] - public async Task DeferredTaskCreation_ElicitWithoutCreatingTask_ReturnsNormalResult() - { - StartServer(); - await using var client = await CreateMcpClientForServer(CreateClientOptions()); - - var result = await CallToolWithTaskMetadataAsync(client, "ask-question", - new Dictionary { ["question"] = "How are you?" }); - - // Tool returned without calling CreateTaskAsync — normal result, no task. - Assert.Null(result.Task); - var content = Assert.Single(result.Content); - Assert.Equal("Answer: confirm", Assert.IsType(content).Text); - } - - [Fact] - public async Task DeferredTaskCreation_WithoutTaskMetadata_NormalExecution() - { - StartServer(); - await using var client = await CreateMcpClientForServer(CreateClientOptions()); - - // Call without task metadata — the tool does MRTR normally, no task involved. - var result = await client.CallToolAsync("ask-question", - new Dictionary { ["question"] = "No task" }, - cancellationToken: TestContext.Current.CancellationToken); - - Assert.Null(result.Task); - Assert.Equal("Answer: confirm", Assert.IsType(Assert.Single(result.Content)).Text); - } - - [Fact] - public async Task DeferredTaskCreation_MultipleRoundsThenCreateTask_AllRoundsComplete() - { - StartServer(); - var elicitCount = 0; - var handlers = new McpClientHandlers - { - ElicitationHandler = (request, ct) => - { - var count = Interlocked.Increment(ref elicitCount); - var value = count == 1 ? "Alice" : "alice@example.com"; - return new ValueTask(new ElicitResult - { - Action = value, - Content = new Dictionary() - }); - } - }; - - await using var client = await CreateMcpClientForServer(CreateClientOptions(handlers)); - - var result = await CallToolWithTaskMetadataAsync(client, "multi-round-then-task"); - - // Should have created a task after two MRTR rounds. - Assert.NotNull(result.Task); - Assert.Equal(2, elicitCount); - - var taskStatus = await WaitForTaskCompletionAsync(result.Task.TaskId); - Assert.Equal(McpTaskStatus.Completed, taskStatus.Status); - } - - [Fact] - public async Task BackwardsCompat_ImmediateTaskCreation_WorksUnchanged() - { - StartServer(); - await using var client = await CreateMcpClientForServer(CreateClientOptions(new McpClientHandlers())); - - var result = await CallToolWithTaskMetadataAsync(client, "immediate-task-tool", - new Dictionary { ["input"] = "test" }); - - // Immediate task creation — result has task immediately. - Assert.NotNull(result.Task); - - var taskStatus = await WaitForTaskCompletionAsync(result.Task.TaskId); - Assert.Equal(McpTaskStatus.Completed, taskStatus.Status); - } - - [Fact] - public async Task DeferredTaskCreation_AttributeBased_ElicitThenCreateTask() - { - StartServer(); - await using var client = await CreateMcpClientForServer(CreateClientOptions()); - - var result = await CallToolWithTaskMetadataAsync(client, "provision_vm", - new Dictionary { ["vmName"] = "test-vm" }); - - // The attribute-based tool should create a task after MRTR elicitation. - Assert.NotNull(result.Task); - Assert.NotEmpty(result.Task.TaskId); - - var taskStatus = await WaitForTaskCompletionAsync(result.Task.TaskId); - Assert.Equal(McpTaskStatus.Completed, taskStatus.Status); - } - - /// - /// Attribute-based tool type demonstrating deferred task creation. - /// Matches the pattern shown in the MRTR conceptual documentation. - /// - [McpServerToolType] - private sealed class DeferredTaskToolType - { - [McpServerTool(DeferTaskCreation = true, TaskSupport = ToolTaskSupport.Optional)] - [Description("Provisions a VM with user confirmation")] - public static async Task ProvisionVm( - string vmName, McpServer server, CancellationToken ct) - { - var confirmation = await server.ElicitAsync(new ElicitRequestParams - { - Message = $"Provision VM '{vmName}'? This will incur costs.", - RequestedSchema = new() - }, ct); - - if (confirmation.Action != "confirm") - return "Cancelled by user."; - - await server.CreateTaskAsync(ct); - - await Task.Delay(50, ct); - return $"VM '{vmName}' provisioned successfully."; - } - } -} diff --git a/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs deleted file mode 100644 index 7ac972040..000000000 --- a/tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs +++ /dev/null @@ -1,621 +0,0 @@ -#if !NET472 -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using ModelContextProtocol.Client; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; -using ModelContextProtocol.Tests.Utils; -using System.IO.Pipelines; -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace ModelContextProtocol.Tests.Client; - -/// -/// Edge-case and guardrail tests for MRTR over in-memory pipe transport. These focus on -/// scenarios not easily covered by -/// which provides broad happy-path coverage across StreamableHttp, SSE, and Stateless transports. -/// -public class MrtrIntegrationTests : ClientServerTestBase -{ - private readonly ServerMessageTracker _messageTracker = new(); - - public MrtrIntegrationTests(ITestOutputHelper testOutputHelper) - : base(testOutputHelper, startServer: false) - { - } - - protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) - { - services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug)); - services.Configure(options => - { - options.ProtocolVersion = "DRAFT-2026-v1"; - _messageTracker.AddFilters(options.Filters.Message); - }); - - mcpServerBuilder.WithTools([ - McpServerTool.Create( - async (string message, McpServer server, CancellationToken ct) => - { - var result = await server.ElicitAsync(new ElicitRequestParams - { - Message = message, - RequestedSchema = new() - }, ct); - - return $"{result.Action}:{result.Content?.FirstOrDefault().Value}"; - }, - new McpServerToolCreateOptions - { - Name = "elicitation-tool", - Description = "A tool that requests elicitation from the client" - }), - McpServerTool.Create( - async (McpServer server, CancellationToken ct) => - { - // Attempt concurrent ElicitAsync + SampleAsync — MrtrContext prevents this. - var t1 = server.ElicitAsync(new ElicitRequestParams - { - Message = "Concurrent elicit", - RequestedSchema = new() - }, ct).AsTask(); - - var t2 = server.SampleAsync(new CreateMessageRequestParams - { - Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Concurrent sample" }] }], - MaxTokens = 100 - }, ct).AsTask(); - - await Task.WhenAll(t1, t2); - return "done"; - }, - new McpServerToolCreateOptions - { - Name = "concurrent-tool", - Description = "A tool that attempts concurrent elicitation and sampling" - }), - McpServerTool.Create( - (McpServer server) => - { - // Low-level MRTR: throw InputRequiredException directly instead of using ElicitAsync. - // This should NOT be logged at Error level — it's normal MRTR control flow. - throw new InputRequiredException(new InputRequiredResult - { - InputRequests = new Dictionary - { - ["input_1"] = InputRequest.ForElicitation(new ElicitRequestParams - { - Message = "low-level elicit", - RequestedSchema = new() - }) - } - }); - }, - new McpServerToolCreateOptions - { - Name = "incomplete-result-tool", - Description = "A tool that throws InputRequiredException for low-level MRTR" - }), - McpServerTool.Create( - async (McpServer server, RequestContext context, CancellationToken ct) => - { - var requestState = context.Params!.RequestState; - var inputResponses = context.Params!.InputResponses; - - // Final round: we have the requestState from the InputRequiredException - if (requestState == "got-name" && inputResponses is not null - && inputResponses.TryGetValue("age", out var ageResponse)) - { - var age = ageResponse.ElicitationResult?.Content?.FirstOrDefault().Value; - // Decode the name from requestState — in a real scenario, requestState - // would carry the accumulated state, but here we just verify the flow works. - return $"age={age}"; - } - - // First round: use high-level ElicitAsync (handler suspends) - var nameResult = await server.ElicitAsync(new ElicitRequestParams - { - Message = "What is your name?", - RequestedSchema = new() - }, ct); - - var name = nameResult.Content?.FirstOrDefault().Value; - - // Second round: switch to low-level InputRequiredException (handler dies) - throw new InputRequiredException( - inputRequests: new Dictionary - { - ["age"] = InputRequest.ForElicitation(new ElicitRequestParams - { - Message = $"How old are you, {name}?", - RequestedSchema = new() - }) - }, - requestState: "got-name"); - }, - new McpServerToolCreateOptions - { - Name = "elicit-then-incomplete-result-tool", - Description = "A tool that uses high-level ElicitAsync then throws InputRequiredException" - }), - McpServerTool.Create( - async (McpServer server) => - { - // Attempt to send a JsonRpcRequest via SendMessageAsync — should always throw - // since requests must go through SendRequestAsync for response correlation. - try - { - await server.SendMessageAsync(new JsonRpcRequest - { - Id = new RequestId(999), - Method = RequestMethods.ElicitationCreate, - Params = JsonSerializer.SerializeToNode(new ElicitRequestParams - { - Message = "Bypass attempt", - RequestedSchema = new() - }, McpJsonUtilities.DefaultOptions) - }); - return "NOT BLOCKED - expected InvalidOperationException"; - } - catch (InvalidOperationException ex) - { - return $"blocked:{ex.Message}"; - } - }, - new McpServerToolCreateOptions - { - Name = "sendmessage-bypass-tool", - Description = "A tool that attempts to bypass MRTR via SendMessageAsync" - }) - ]); - } - - [Fact] - public async Task CallToolAsync_BothExperimental_ElicitCompletesViaMrtr() - { - // Simplest MRTR success: experimental server + experimental client, one elicitation round. - StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; - clientOptions.Handlers.ElicitationHandler = (request, ct) => - new ValueTask(new ElicitResult - { - Action = "accept", - Content = new Dictionary - { - ["name"] = JsonSerializer.SerializeToElement("Alice", McpJsonUtilities.DefaultOptions) - } - }); - - await using var client = await CreateMcpClientForServer(clientOptions); - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); - - var result = await client.CallToolAsync("elicitation-tool", - new Dictionary { ["message"] = "What is your name?" }, - cancellationToken: TestContext.Current.CancellationToken); - - var text = Assert.IsType(Assert.Single(result.Content)).Text; - Assert.Equal("accept:Alice", text); - Assert.True(result.IsError is not true); - _messageTracker.AssertMrtrUsed(); - } - - [Fact] - public async Task CallToolAsync_ConcurrentElicitAndSample_PropagatesError() - { - // MrtrContext only allows one pending request at a time. When a tool handler - // calls ElicitAsync and SampleAsync concurrently via Task.WhenAll, the second - // call sees the TCS already completed and throws InvalidOperationException. - // That exception is caught by the tool error handler and returned as IsError. - StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; - - // The first concurrent call (ElicitAsync) produces an InputRequiredResult. - // The client resolves it via this handler, which unblocks the first task. - // Then Task.WhenAll surfaces the InvalidOperationException from the second task. - clientOptions.Handlers.ElicitationHandler = (request, ct) => - { - return new ValueTask(new ElicitResult { Action = "accept" }); - }; - clientOptions.Handlers.SamplingHandler = (request, progress, ct) => - { - return new ValueTask(new CreateMessageResult - { - Content = [new TextContentBlock { Text = "sampled" }], - Model = "test-model" - }); - }; - - await using var client = await CreateMcpClientForServer(clientOptions); - - var result = await client.CallToolAsync("concurrent-tool", - cancellationToken: TestContext.Current.CancellationToken); - - Assert.True(result.IsError); - var errorText = Assert.IsType(Assert.Single(result.Content)).Text; - Assert.Contains("concurrent-tool", errorText); - _messageTracker.AssertMrtrUsed(); - } - - [Fact] - public async Task CallToolAsync_ElicitThenIncompleteResultException_WorksEndToEnd() - { - // Verify that a handler can mix high-level MRTR (ElicitAsync) with low-level MRTR - // (InputRequiredException) in a single logical flow. The handler: - // 1. Calls ElicitAsync (high-level: handler suspends, InputRequiredResult returned) - // 2. Gets the response, then throws InputRequiredException (low-level: handler dies) - // 3. On the next retry, a fresh handler invocation processes requestState + inputResponses - StartServer(); - int elicitationCallCount = 0; - - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; - clientOptions.Handlers.ElicitationHandler = (request, ct) => - { - elicitationCallCount++; - if (request?.Message == "What is your name?") - { - return new ValueTask(new ElicitResult - { - Action = "accept", - Content = new Dictionary - { - ["name"] = JsonDocument.Parse("\"Alice\"").RootElement.Clone() - } - }); - } - - // Second elicitation from the InputRequiredException path - return new ValueTask(new ElicitResult - { - Action = "accept", - Content = new Dictionary - { - ["age"] = JsonDocument.Parse("\"30\"").RootElement.Clone() - } - }); - }; - - await using var client = await CreateMcpClientForServer(clientOptions); - - var result = await client.CallToolAsync( - "elicit-then-incomplete-result-tool", - cancellationToken: TestContext.Current.CancellationToken); - - // Verify the final result came through correctly - var content = Assert.Single(result.Content); - Assert.Equal("age=30", Assert.IsType(content).Text); - Assert.NotEqual(true, result.IsError); - - // Two elicitations: one from ElicitAsync, one from InputRequiredException's inputRequests - Assert.Equal(2, elicitationCallCount); - - // Verify no error-level logs for InputRequiredException - Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => - m.LogLevel == LogLevel.Error && - m.Exception is InputRequiredException); - _messageTracker.AssertMrtrUsed(); - } - - [Fact] - public async Task ClientHandlerException_DuringMrtrInputResolution_SurfacesToCaller() - { - // When the CLIENT's elicitation handler throws during MRTR input resolution, - // the retry never reaches the server — the server's handler remains suspended - // on ElicitAsync(). The exception should surface to the CallToolAsync caller, - // and the server's orphaned handler should be cleaned up on disposal. - // This is a fundamental MRTR limitation: the client has no channel to communicate - // input resolution failures back to the server. - StartServer(); - - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; - clientOptions.Handlers.ElicitationHandler = (request, ct) => - { - throw new InvalidOperationException("Client-side elicitation failure"); - }; - - await using var client = await CreateMcpClientForServer(clientOptions); - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); - - // The client handler throws during input resolution, so the exception - // escapes ResolveInputRequestAsync and surfaces directly to the caller. - var ex = await Assert.ThrowsAsync(async () => - await client.CallToolAsync("elicitation-tool", - new Dictionary { ["message"] = "Will fail" }, - cancellationToken: TestContext.Current.CancellationToken)); - - Assert.Equal("Client-side elicitation failure", ex.Message); - - // Dispose the server to trigger cleanup of the orphaned MRTR continuation. - // The server should cancel the handler suspended on ElicitAsync() and log - // the cancelled continuation at Debug level. - await Server.DisposeAsync(); - - Assert.Contains(MockLoggerProvider.LogMessages, m => - m.LogLevel == LogLevel.Debug && - m.Message.Contains("Cancelled") && - m.Message.Contains("MRTR continuation")); - } - - [Fact] - public async Task SendMessageAsync_WithJsonRpcRequest_ThrowsAlways() - { - // SendMessageAsync should throw InvalidOperationException if the message is a - // JsonRpcRequest, regardless of MRTR state. Use SendRequestAsync for requests. - StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; - clientOptions.Handlers.ElicitationHandler = (request, ct) => - new ValueTask(new ElicitResult { Action = "accept" }); - - await using var client = await CreateMcpClientForServer(clientOptions); - - var result = await client.CallToolAsync("sendmessage-bypass-tool", - cancellationToken: TestContext.Current.CancellationToken); - - var text = Assert.IsType(Assert.Single(result.Content)).Text; - Assert.StartsWith("blocked:", text); - Assert.Contains("SendMessageAsync", text); - Assert.Contains("SendRequestAsync", text); - } - - [Fact] - public async Task LegacyRequestOnMrtrSession_LogsWarning() - { - // This test simulates a non-compliant server that negotiates MRTR - // but sends legacy elicitation/create JSON-RPC requests instead of - // using InputRequiredResult. The client should handle it but log a warning. - StartServer(); // Required for base class DisposeAsync cleanup - var clientToServer = new Pipe(); - var serverToClient = new Pipe(); - - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; - clientOptions.Handlers.ElicitationHandler = (request, ct) => - new ValueTask(new ElicitResult { Action = "accept" }); - clientOptions.Handlers.SamplingHandler = (request, progress, ct) => - new ValueTask(new CreateMessageResult - { - Content = [new TextContentBlock { Text = "sampled" }], - Model = "test-model" - }); - - // Start the client task — it will send initialize and block waiting for response - var clientTask = McpClient.CreateAsync( - new StreamClientTransport( - clientToServer.Writer.AsStream(), - serverToClient.Reader.AsStream(), - LoggerFactory), - clientOptions, - loggerFactory: LoggerFactory, - cancellationToken: TestContext.Current.CancellationToken); - - // Simulate server: read initialize request, respond with experimental version - var serverReader = new StreamReader(clientToServer.Reader.AsStream()); - var serverWriter = serverToClient.Writer.AsStream(); - - // Read the initialize request from client - var initLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); - Assert.NotNull(initLine); - var initRequest = JsonSerializer.Deserialize(initLine, McpJsonUtilities.DefaultOptions); - Assert.NotNull(initRequest); - Assert.Equal("initialize", initRequest.Method); - - // Respond with experimental protocol version (MRTR negotiated) - var initResponse = new JsonRpcResponse - { - Id = initRequest.Id, - Result = JsonSerializer.SerializeToNode(new InitializeResult - { - ProtocolVersion = "DRAFT-2026-v1", - Capabilities = new ServerCapabilities(), - ServerInfo = new Implementation { Name = "MockMrtrServer", Version = "1.0" } - }, McpJsonUtilities.DefaultOptions), - }; - await WriteJsonRpcAsync(serverWriter, initResponse); - - // Read the initialized notification from client - var initializedLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); - Assert.NotNull(initializedLine); - - // Client is now connected with MRTR negotiated - await using var client = await clientTask; - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); - - // Now simulate the non-compliant server sending a legacy elicitation/create request - var legacyRequest = new JsonRpcRequest - { - Id = new RequestId(42), - Method = RequestMethods.ElicitationCreate, - Params = JsonSerializer.SerializeToNode(new ElicitRequestParams - { - Message = "Legacy elicitation from non-compliant server", - RequestedSchema = new() - }, McpJsonUtilities.DefaultOptions), - }; - await WriteJsonRpcAsync(serverWriter, legacyRequest); - - // Read the client's response to the legacy request - var responseLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); - Assert.NotNull(responseLine); - var clientResponse = JsonSerializer.Deserialize(responseLine, McpJsonUtilities.DefaultOptions); - Assert.NotNull(clientResponse); - Assert.Equal(new RequestId(42), clientResponse.Id); - - // Verify the client handled the request (returned ElicitResult) - var elicitResult = JsonSerializer.Deserialize(clientResponse.Result, McpJsonUtilities.DefaultOptions); - Assert.NotNull(elicitResult); - Assert.Equal("accept", elicitResult.Action); - - // Verify the warning was logged - Assert.Contains(MockLoggerProvider.LogMessages, m => - m.LogLevel == LogLevel.Warning && - m.Message.Contains("elicitation/create") && - m.Message.Contains("MRTR")); - - // Clean up - clientToServer.Writer.Complete(); - serverToClient.Writer.Complete(); - } - - [Fact] - public async Task IncompleteResultOnNonMrtrSession_LogsWarning() - { - // This test simulates a non-compliant server that sends an InputRequiredResult - // to a client that did NOT negotiate MRTR. The client should still process it - // (resilience), but log a warning about the unexpected protocol behavior. - StartServer(); // Required for base class DisposeAsync cleanup - var clientToServer = new Pipe(); - var serverToClient = new Pipe(); - - // Client does NOT set DRAFT-2026-v1 — standard protocol only - var clientOptions = new McpClientOptions(); - clientOptions.Handlers.ElicitationHandler = (request, ct) => - new ValueTask(new ElicitResult - { - Action = "accept", - Content = new Dictionary - { - ["confirmed"] = JsonDocument.Parse("\"yes\"").RootElement.Clone() - } - }); - - // Start the client task — it will send initialize and block waiting for response - var clientTask = McpClient.CreateAsync( - new StreamClientTransport( - clientToServer.Writer.AsStream(), - serverToClient.Reader.AsStream(), - LoggerFactory), - clientOptions, - loggerFactory: LoggerFactory, - cancellationToken: TestContext.Current.CancellationToken); - - var serverReader = new StreamReader(clientToServer.Reader.AsStream()); - var serverWriter = serverToClient.Writer.AsStream(); - - // Read the initialize request from client - var initLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); - Assert.NotNull(initLine); - var initRequest = JsonSerializer.Deserialize(initLine, McpJsonUtilities.DefaultOptions); - Assert.NotNull(initRequest); - Assert.Equal("initialize", initRequest.Method); - - // Respond with standard protocol version (no MRTR) - var initResponse = new JsonRpcResponse - { - Id = initRequest.Id, - Result = JsonSerializer.SerializeToNode(new InitializeResult - { - ProtocolVersion = "2025-03-26", - Capabilities = new ServerCapabilities { Tools = new() }, - ServerInfo = new Implementation { Name = "NonCompliantServer", Version = "1.0" } - }, McpJsonUtilities.DefaultOptions), - }; - await WriteJsonRpcAsync(serverWriter, initResponse); - - // Read the initialized notification from client - var initializedLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken); - Assert.NotNull(initializedLine); - - // Client is now connected with standard protocol (no MRTR) - await using var client = await clientTask; - Assert.Equal("2025-03-26", client.NegotiatedProtocolVersion); - - // Start a background task to handle the client's tools/call request - var cancellationToken = TestContext.Current.CancellationToken; - var serverLoop = Task.Run(async () => - { - // Read tools/call request from client - var callLine = await serverReader.ReadLineAsync(cancellationToken); - Assert.NotNull(callLine); - var callRequest = JsonSerializer.Deserialize(callLine, McpJsonUtilities.DefaultOptions); - Assert.NotNull(callRequest); - Assert.Equal("tools/call", callRequest.Method); - - // Non-compliant server sends InputRequiredResult on standard protocol session! - var InputRequiredResult = new JsonObject - { - ["resultType"] = "input_required", - ["inputRequests"] = new JsonObject - { - ["confirm_1"] = JsonSerializer.SerializeToNode( - InputRequest.ForElicitation(new ElicitRequestParams - { - Message = "Unexpected elicitation from non-compliant server", - RequestedSchema = new() - }), McpJsonUtilities.DefaultOptions) - }, - ["requestState"] = "non-mrtr-state" - }; - - var incompleteResponse = new JsonRpcResponse - { - Id = callRequest.Id, - Result = InputRequiredResult, - }; - await WriteJsonRpcAsync(serverWriter, incompleteResponse); - - // Read the retry request with inputResponses from client - var retryLine = await serverReader.ReadLineAsync(cancellationToken); - Assert.NotNull(retryLine); - var retryRequest = JsonSerializer.Deserialize(retryLine, McpJsonUtilities.DefaultOptions); - Assert.NotNull(retryRequest); - Assert.Equal("tools/call", retryRequest.Method); - - // Verify the retry contains inputResponses and requestState - var retryParams = retryRequest.Params as JsonObject; - Assert.NotNull(retryParams); - Assert.NotNull(retryParams["inputResponses"]); - Assert.Equal("non-mrtr-state", retryParams["requestState"]?.GetValue()); - - // Now respond with a normal result - var normalResult = new JsonRpcResponse - { - Id = retryRequest.Id, - Result = JsonSerializer.SerializeToNode(new CallToolResult - { - Content = [new TextContentBlock { Text = "completed-without-mrtr" }] - }, McpJsonUtilities.DefaultOptions), - }; - await WriteJsonRpcAsync(serverWriter, normalResult); - }, cancellationToken); - - // Client calls the tool — the non-compliant server will send InputRequiredResult - var response = await client.SendRequestAsync( - new JsonRpcRequest - { - Method = "tools/call", - Params = JsonSerializer.SerializeToNode(new CallToolRequestParams - { - Name = "any-tool", - }, McpJsonUtilities.DefaultOptions) - }, - cancellationToken); - - await serverLoop; - - Assert.NotNull(response.Result); - var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); - Assert.NotNull(result); - var content = Assert.Single(result.Content); - Assert.Equal("completed-without-mrtr", Assert.IsType(content).Text); - - // Verify the warning was logged about InputRequiredResult on non-MRTR session - Assert.Contains(MockLoggerProvider.LogMessages, m => - m.LogLevel == LogLevel.Warning && - m.Message.Contains("InputRequiredResult") && - m.Message.Contains("did not negotiate MRTR")); - - // Clean up - clientToServer.Writer.Complete(); - serverToClient.Writer.Complete(); - } - - private static async Task WriteJsonRpcAsync(Stream writer, JsonRpcMessage message) - { - var bytes = JsonSerializer.SerializeToUtf8Bytes(message, McpJsonUtilities.DefaultOptions); - await writer.WriteAsync(bytes, TestContext.Current.CancellationToken); - await writer.WriteAsync("\n"u8.ToArray(), TestContext.Current.CancellationToken); - await writer.FlushAsync(TestContext.Current.CancellationToken); - } -} - -#endif diff --git a/tests/ModelContextProtocol.Tests/Server/DraftProtocolGuardTests.cs b/tests/ModelContextProtocol.Tests/Server/DraftProtocolGuardTests.cs new file mode 100644 index 000000000..e74ad20ba --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/DraftProtocolGuardTests.cs @@ -0,0 +1,109 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Server; + +public sealed class DraftProtocolGuardTests : ClientServerTestBase +{ + public DraftProtocolGuardTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + services.Configure(options => + { + options.ProtocolVersion = "DRAFT-2026-v1"; + }); + + mcpServerBuilder.WithTools([ + McpServerTool.Create(AssertElicitAsyncGuardAsync, new() { Name = "assert-elicit-guard" }), + McpServerTool.Create(AssertSampleAsyncGuardAsync, new() { Name = "assert-sample-guard" }), + McpServerTool.Create(AssertRequestRootsAsyncGuardAsync, new() { Name = "assert-roots-guard" }), + ]); + } + + [Fact] + public async Task ElicitAsync_ThrowsUnderDraftProtocol() + { + StartServer(); + await using var client = await CreateDraftClientAsync(); + + var result = await client.CallToolAsync("assert-elicit-guard", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("ok", Assert.IsType(result.Content[0]).Text); + } + + [Fact] + public async Task SampleAsync_ThrowsUnderDraftProtocol() + { + StartServer(); + await using var client = await CreateDraftClientAsync(); + + var result = await client.CallToolAsync("assert-sample-guard", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("ok", Assert.IsType(result.Content[0]).Text); + } + + [Fact] + public async Task RequestRootsAsync_ThrowsUnderDraftProtocol() + { + StartServer(); + await using var client = await CreateDraftClientAsync(); + + var result = await client.CallToolAsync("assert-roots-guard", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("ok", Assert.IsType(result.Content[0]).Text); + } + + private Task CreateDraftClientAsync() => + CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }); + + private static async Task AssertElicitAsyncGuardAsync(McpServer server, CancellationToken cancellationToken) + { + var exception = await Assert.ThrowsAsync(() => + server.ElicitAsync(new ElicitRequestParams + { + Message = "Need input", + RequestedSchema = new(), + }, cancellationToken).AsTask()); + + Assert.Contains("DRAFT-2026-v1", exception.Message); + Assert.Contains("InputRequest.ForElicitation", exception.Message); + return "ok"; + } + + private static async Task AssertSampleAsyncGuardAsync(McpServer server, CancellationToken cancellationToken) + { + var exception = await Assert.ThrowsAsync(() => + server.SampleAsync(new CreateMessageRequestParams + { + Messages = + [ + new SamplingMessage + { + Role = Role.User, + Content = [new TextContentBlock { Text = "Hello" }], + }, + ], + MaxTokens = 1, + }, cancellationToken).AsTask()); + + Assert.Contains("DRAFT-2026-v1", exception.Message); + Assert.Contains("InputRequest.ForSampling", exception.Message); + return "ok"; + } + + private static async Task AssertRequestRootsAsyncGuardAsync(McpServer server, CancellationToken cancellationToken) + { + var exception = await Assert.ThrowsAsync(() => + server.RequestRootsAsync(new ListRootsRequestParams(), cancellationToken).AsTask()); + + Assert.Contains("DRAFT-2026-v1", exception.Message); + Assert.Contains("InputRequest.ForRootsList", exception.Message); + return "ok"; + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index b8bd57b02..64de19b26 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -1370,7 +1370,7 @@ public override Task SendRequestAsync(JsonRpcRequest request, C public override ValueTask DisposeAsync() => default; public override string? SessionId => throw new NotImplementedException(); - public override string? NegotiatedProtocolVersion => throw new NotImplementedException(); + public override string? NegotiatedProtocolVersion => null; public override Implementation? ClientInfo => throw new NotImplementedException(); public override IServiceProvider? Services => throw new NotImplementedException(); public override LoggingLevel? LoggingLevel => throw new NotImplementedException(); diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs deleted file mode 100644 index 0b3db8056..000000000 --- a/tests/ModelContextProtocol.Tests/Server/MrtrHandlerLifecycleTests.cs +++ /dev/null @@ -1,438 +0,0 @@ -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using ModelContextProtocol.Client; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; -using ModelContextProtocol.Tests.Utils; -using System.Text.Json; - -namespace ModelContextProtocol.Tests.Server; - -/// -/// Tests for the server's MRTR handler lifecycle management — cancellation, disposal, and error -/// logging during multi round-trip request processing. -/// -public class MrtrHandlerLifecycleTests : ClientServerTestBase -{ - private readonly TaskCompletionSource _handlerTokenCancelled = new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly TaskCompletionSource _handlerStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly TaskCompletionSource _handlerResumed = new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly TaskCompletionSource _releaseHandler = new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly ServerMessageTracker _messageTracker = new(); - - public MrtrHandlerLifecycleTests(ITestOutputHelper testOutputHelper) - : base(testOutputHelper, startServer: false) - { - } - - protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) - { - services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug)); - services.Configure(options => - { - options.ProtocolVersion = "DRAFT-2026-v1"; - _messageTracker.AddFilters(options.Filters.Message); - }); - - mcpServerBuilder.WithTools([ - McpServerTool.Create( - async (string message, McpServer server, CancellationToken ct) => - { - var result = await server.ElicitAsync(new ElicitRequestParams - { - Message = message, - RequestedSchema = new() - }, ct); - - return $"{result.Action}:{result.Content?.FirstOrDefault().Value}"; - }, - new McpServerToolCreateOptions - { - Name = "elicitation-tool", - Description = "A tool that requests elicitation from the client" - }), - McpServerTool.Create( - async (McpServer server, CancellationToken ct) => - { - var handlerTokenCancelled = _handlerTokenCancelled; - ct.Register(static state => ((TaskCompletionSource)state!).TrySetResult(true), handlerTokenCancelled); - _handlerStarted.TrySetResult(true); - - await server.ElicitAsync(new ElicitRequestParams - { - Message = "Cancellation test", - RequestedSchema = new() - }, ct); - - return "done"; - }, - new McpServerToolCreateOptions - { - Name = "cancellation-test-tool", - Description = "A tool that monitors its CancellationToken during MRTR" - }), - McpServerTool.Create( - async (string message, McpServer server, CancellationToken ct) => - { - // Elicit first, then block forever — the retry request stays in-flight - // until the client cancels, verifying that notifications/cancelled for - // the retry's request ID flows through to cancel this handler. - _handlerStarted.TrySetResult(true); - var result = await server.ElicitAsync(new ElicitRequestParams - { - Message = message, - RequestedSchema = new() - }, ct); - - // Signal that we resumed after ElicitAsync, then block. - _handlerResumed.TrySetResult(true); - await Task.Delay(Timeout.Infinite, ct); - return "unreachable"; - }, - new McpServerToolCreateOptions - { - Name = "elicit-then-block-tool", - Description = "A tool that elicits then blocks forever for cancellation testing" - }), - McpServerTool.Create( - async (McpServer server, CancellationToken ct) => - { - // Two sequential MRTR rounds. The client will inject a stale cancellation - // notification for the original request ID between round 1 and round 2. - var r1 = await server.ElicitAsync(new ElicitRequestParams - { - Message = "First elicitation", - RequestedSchema = new() - }, ct); - - // Signal that round 1 completed so the test can inject the stale notification. - _handlerResumed.TrySetResult(true); - - var r2 = await server.ElicitAsync(new ElicitRequestParams - { - Message = "Second elicitation", - RequestedSchema = new() - }, ct); - - return $"{r1.Action},{r2.Action}"; - }, - new McpServerToolCreateOptions - { - Name = "double-elicit-tool", - Description = "A tool that elicits twice for stale cancellation testing" - }), - McpServerTool.Create( - async (string message, McpServer server, CancellationToken ct) => - { - // Elicit, resume, then wait on _releaseHandler for the dispose test. - _handlerStarted.TrySetResult(true); - await server.ElicitAsync(new ElicitRequestParams - { - Message = message, - RequestedSchema = new() - }, ct); - - _handlerResumed.TrySetResult(true); - await _releaseHandler.Task; - return "handler-completed"; - }, - new McpServerToolCreateOptions - { - Name = "dispose-wait-tool", - Description = "A tool that elicits, resumes, then waits on a signal for disposal testing" - }), - McpServerTool.Create( - async (McpServer server, CancellationToken ct) => - { - await server.ElicitAsync(new ElicitRequestParams - { - Message = "elicit-then-throw", - RequestedSchema = new() - }, ct); - - throw new InvalidOperationException("Deliberate MRTR handler error for testing"); - }, - new McpServerToolCreateOptions - { - Name = "elicit-then-throw-tool", - Description = "A tool that elicits then throws an exception for error logging testing" - }), - McpServerTool.Create( - (McpServer server) => - { - // Low-level MRTR: throw InputRequiredException directly instead of using ElicitAsync. - // This should NOT be logged at Error level — it's normal MRTR control flow. - throw new InputRequiredException(new InputRequiredResult - { - InputRequests = new Dictionary - { - ["input_1"] = InputRequest.ForElicitation(new ElicitRequestParams - { - Message = "low-level elicit", - RequestedSchema = new() - }) - } - }); - }, - new McpServerToolCreateOptions - { - Name = "incomplete-result-tool", - Description = "A tool that throws InputRequiredException for low-level MRTR" - }) - ]); - } - - [Fact] - public async Task CallToolAsync_CancellationDuringMrtrRetry_ThrowsOperationCanceled() - { - // Verify that cancelling the CancellationToken during the MRTR retry loop - // (specifically during the elicitation handler callback) stops the loop. - StartServer(); - var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; - clientOptions.Handlers.ElicitationHandler = (request, ct) => - { - // Cancel the token during the callback. The retry loop will throw - // OperationCanceledException on the next await after this handler returns. - cts.Cancel(); - return new ValueTask(new ElicitResult { Action = "accept" }); - }; - - await using var client = await CreateMcpClientForServer(clientOptions); - - await Assert.ThrowsAsync(async () => - await client.CallToolAsync("elicitation-tool", - new Dictionary { ["message"] = "test" }, - cancellationToken: cts.Token)); - - _messageTracker.AssertMrtrUsed(); - } - - [Fact] - public async Task ServerDisposal_CancelsHandlerCancellationToken_DuringMrtr() - { - // Verify that disposing the server cancels the handler's own CancellationToken - // (the `ct` parameter), not just the exchange ResponseTcs. Before the HandlerCts fix, - // the handler's CT was from a disposed CTS and could never be triggered. - StartServer(); - var elicitHandlerCalled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; - clientOptions.Handlers.ElicitationHandler = async (request, ct) => - { - // Signal that the MRTR round trip reached the client, then block indefinitely. - elicitHandlerCalled.TrySetResult(true); - await Task.Delay(Timeout.Infinite, ct); - throw new OperationCanceledException(ct); - }; - - await using var client = await CreateMcpClientForServer(clientOptions); - - // Start the tool call in the background. - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - cts.CancelAfter(TimeSpan.FromSeconds(30)); - var callTask = client.CallToolAsync("cancellation-test-tool", cancellationToken: cts.Token).AsTask(); - - // Wait for the handler to start on the server. - await _handlerStarted.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); - - // Wait for the MRTR round trip to reach the client's elicitation handler. - await elicitHandlerCalled.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); - - // Dispose the server — HandlerCts.Cancel() should trigger the handler's CancellationToken. - await Server.DisposeAsync(); - - // Verify the handler's CancellationToken was actually cancelled via HandlerCts, - // not just the exchange ResponseTcs.TrySetCanceled(). - await _handlerTokenCancelled.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); - - // The client call should fail (server disposed mid-MRTR). - await Assert.ThrowsAnyAsync(async () => await callTask); - } - - [Fact] - public async Task CancellationNotification_DuringInFlightMrtrRetry_CancelsHandler() - { - // Verify that cancelling the client's CancellationToken while a retry request is in-flight - // sends notifications/cancelled with the retry's request ID, and the server correctly - // routes it to cancel the handler. This proves end-to-end that: - // (a) the client sends the notification with the CURRENT request ID (not the original), - // (b) the server's _handlingRequests lookup finds the retry's CTS, - // (c) the cancellation registration in AwaitMrtrHandlerAsync bridges to handlerCts. - StartServer(); - - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; - clientOptions.Handlers.ElicitationHandler = (request, ct) => - new ValueTask(new ElicitResult { Action = "accept" }); - - await using var client = await CreateMcpClientForServer(clientOptions); - - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - cts.CancelAfter(TimeSpan.FromSeconds(30)); - var callTask = client.CallToolAsync( - "elicit-then-block-tool", - new Dictionary { ["message"] = "test" }, - cancellationToken: cts.Token).AsTask(); - - // Wait for the handler to resume after ElicitAsync — at this point the retry - // request is in-flight (server is awaiting WhenAny in AwaitMrtrHandlerAsync). - await _handlerResumed.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); - - // Cancel the client's token. The client is inside _sessionHandler.SendRequestAsync - // awaiting the retry response. RegisterCancellation fires and sends - // notifications/cancelled with the retry's request ID. - cts.Cancel(); - - // The call should throw OperationCanceledException. - await Assert.ThrowsAnyAsync(async () => await callTask); - - _messageTracker.AssertMrtrUsed(); - } - - [Fact] - public async Task CancellationNotification_ForExpiredRequestId_DoesNotAffectHandler() - { - // Verify that a stale cancellation notification for the original (now-completed) - // request ID does not interfere with an active MRTR handler. The original request's - // entry was removed from _handlingRequests when it returned InputRequiredResult, so - // the notification should be a no-op. - StartServer(); - - int elicitationCount = 0; - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; - clientOptions.Handlers.ElicitationHandler = (request, ct) => - { - Interlocked.Increment(ref elicitationCount); - return new ValueTask(new ElicitResult { Action = "accept" }); - }; - - await using var client = await CreateMcpClientForServer(clientOptions); - - // Start the double-elicit tool. Between round 1 and round 2, we'll inject a stale - // cancellation notification for a fake (expired) request ID. - var callTask = client.CallToolAsync( - "double-elicit-tool", - cancellationToken: TestContext.Current.CancellationToken).AsTask(); - - // Wait for handler to resume after the first ElicitAsync. - await _handlerResumed.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); - - // Send a stale cancellation notification for a non-existent request ID. - // This simulates a delayed notification for the original request that already completed. - await client.SendMessageAsync(new JsonRpcNotification - { - Method = NotificationMethods.CancelledNotification, - Params = JsonSerializer.SerializeToNode( - new CancelledNotificationParams { RequestId = new RequestId("stale-id-999"), Reason = "stale test" }, - McpJsonUtilities.DefaultOptions), - }, TestContext.Current.CancellationToken); - - // The tool should complete successfully — the stale notification didn't affect it. - var result = await callTask; - Assert.Contains("accept", result.Content.OfType().First().Text); - - _messageTracker.AssertMrtrUsed(); - } - - [Fact] - public async Task DisposeAsync_WaitsForMrtrHandler_BeforeReturning() - { - // Verify that McpServer.DisposeAsync() waits for an MRTR handler to complete - // before returning, similar to RunAsync_WaitsForInFlightHandlersBeforeReturning - // which tests the same invariant for regular request handlers in McpSessionHandler. - StartServer(); - bool handlerCompleted = false; - - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; - clientOptions.Handlers.ElicitationHandler = (request, ct) => - new ValueTask(new ElicitResult { Action = "accept" }); - - await using var client = await CreateMcpClientForServer(clientOptions); - - // Start the tool call that calls ElicitAsync, then blocks on _releaseHandler. - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - cts.CancelAfter(TimeSpan.FromSeconds(30)); - _ = client.CallToolAsync( - "dispose-wait-tool", - new Dictionary { ["message"] = "dispose-wait-test" }, - cancellationToken: cts.Token); - - // Wait for the handler to resume after ElicitAsync — it's now blocking on _releaseHandler. - await _handlerResumed.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken); - - // Dispose the server. The handler is still running (blocked on _releaseHandler). - // Release the handler after a delay — DisposeAsync must wait for it. - var ct = TestContext.Current.CancellationToken; - _ = Task.Run(async () => - { - await Task.Delay(200, ct); - handlerCompleted = true; - _releaseHandler.SetResult(true); - }, ct); - - await Server.DisposeAsync(); - - // DisposeAsync should not have returned until the handler completed. - Assert.True(handlerCompleted, "DisposeAsync should wait for MRTR handlers to complete before returning."); - - _messageTracker.AssertMrtrUsed(); - } - - [Fact] - public async Task HandlerException_DuringMrtr_IsLoggedAtErrorLevel() - { - // Verify that when a tool handler throws an unhandled exception during MRTR - // (after resuming from ElicitAsync), the error is logged at Error level. - StartServer(); - - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; - clientOptions.Handlers.ElicitationHandler = (request, ct) => - new ValueTask(new ElicitResult { Action = "accept" }); - - await using var client = await CreateMcpClientForServer(clientOptions); - - // Call the tool that elicits then throws. The retry returns an error result. - var result = await client.CallToolAsync( - "elicit-then-throw-tool", - cancellationToken: TestContext.Current.CancellationToken); - Assert.True(result.IsError); - - // Verify the tool error was logged at Error level during the MRTR retry. - // The ToolsCall handler catches the exception, logs it via ToolCallError, - // and converts it to an error result — so the error is properly surfaced. - Assert.Contains(MockLoggerProvider.LogMessages, m => - m.LogLevel == LogLevel.Error && - m.Message.Contains("elicit-then-throw-tool") && - m.Exception is InvalidOperationException); - - _messageTracker.AssertMrtrUsed(); - } - - [Fact] - public async Task IncompleteResultException_IsNotLoggedAtErrorLevel() - { - // InputRequiredException is normal MRTR control flow (low-level API), - // not an error. It should not be logged via ToolCallError at Error level. - StartServer(); - - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; - clientOptions.Handlers.ElicitationHandler = (request, ct) => - new ValueTask(new ElicitResult { Action = "accept" }); - - await using var client = await CreateMcpClientForServer(clientOptions); - - // The tool always throws InputRequiredException (low-level MRTR path), - // so the client will retry until hitting the max retry limit. - await Assert.ThrowsAsync(() => client.CallToolAsync( - "incomplete-result-tool", - cancellationToken: TestContext.Current.CancellationToken).AsTask()); - - Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => - m.LogLevel == LogLevel.Error && - m.Exception is InputRequiredException); - - _messageTracker.AssertMrtrUsed(); - } -} diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs deleted file mode 100644 index 309086db4..000000000 --- a/tests/ModelContextProtocol.Tests/Server/MrtrMessageFilterTests.cs +++ /dev/null @@ -1,149 +0,0 @@ -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; -using ModelContextProtocol.Client; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; -using ModelContextProtocol.Tests.Utils; -using System.Text.Json; - -namespace ModelContextProtocol.Tests.Server; - -/// -/// Tests that message filters correctly observe MRTR protocol behavior — verifying that -/// InputRequiredResult responses are visible to outgoing filters, and that no legacy -/// elicitation/sampling requests are sent when MRTR is active. -/// -public class MrtrMessageFilterTests : ClientServerTestBase -{ - private readonly ServerMessageTracker _messageTracker = new(); - - public MrtrMessageFilterTests(ITestOutputHelper testOutputHelper) - : base(testOutputHelper, startServer: false) - { - } - - protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) - { - services.Configure(options => - { - options.ProtocolVersion = "DRAFT-2026-v1"; - _messageTracker.AddFilters(options.Filters.Message); - }); - - mcpServerBuilder - .WithTools([ - McpServerTool.Create( - async (string message, McpServer server, CancellationToken ct) => - { - var result = await server.ElicitAsync(new ElicitRequestParams - { - Message = message, - RequestedSchema = new() - }, ct); - - return $"{result.Action}"; - }, - new McpServerToolCreateOptions - { - Name = "elicit-tool", - Description = "A tool that requests elicitation" - }), - McpServerTool.Create( - async (string prompt, McpServer server, CancellationToken ct) => - { - var result = await server.SampleAsync(new CreateMessageRequestParams - { - Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = prompt }] }], - MaxTokens = 100 - }, ct); - - return result.Content.OfType().FirstOrDefault()?.Text ?? ""; - }, - new McpServerToolCreateOptions - { - Name = "sample-tool", - Description = "A tool that requests sampling" - }), - ]); - } - - [Fact] - public async Task MrtrActive_NoOldStyleElicitationRequests_SentOverWire() - { - // When both sides are on the experimental protocol, the server should use MRTR - // (InputRequiredResult) instead of sending old-style elicitation/create JSON-RPC requests. - StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; - clientOptions.Handlers.ElicitationHandler = (request, ct) => - { - return new ValueTask(new ElicitResult { Action = "accept" }); - }; - - await using var client = await CreateMcpClientForServer(clientOptions); - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); - - var result = await client.CallToolAsync("elicit-tool", - new Dictionary { ["message"] = "test" }, - cancellationToken: TestContext.Current.CancellationToken); - - var content = Assert.Single(result.Content); - Assert.Equal("accept", Assert.IsType(content).Text); - _messageTracker.AssertMrtrUsed(); - } - - [Fact] - public async Task MrtrActive_NoOldStyleSamplingRequests_SentOverWire() - { - StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; - clientOptions.Handlers.SamplingHandler = (request, progress, ct) => - { - var text = request?.Messages[^1].Content.OfType().FirstOrDefault()?.Text; - return new ValueTask(new CreateMessageResult - { - Content = [new TextContentBlock { Text = $"Sampled: {text}" }], - Model = "test-model" - }); - }; - - await using var client = await CreateMcpClientForServer(clientOptions); - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); - - var result = await client.CallToolAsync("sample-tool", - new Dictionary { ["prompt"] = "test" }, - cancellationToken: TestContext.Current.CancellationToken); - - var content = Assert.Single(result.Content); - Assert.Equal("Sampled: test", Assert.IsType(content).Text); - _messageTracker.AssertMrtrUsed(); - } - - [Fact] - public async Task OutgoingFilter_SeesIncompleteResultResponse() - { - // Verify that transport middleware can observe the raw InputRequiredResult - // in outgoing JSON-RPC responses (validates MRTR transport visibility). - var sawIncompleteResult = false; - - StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; - clientOptions.Handlers.ElicitationHandler = (request, ct) => - { - // If we reach this handler, it means the client received an InputRequiredResult - // from the server, resolved the elicitation, and is retrying. - sawIncompleteResult = true; - return new ValueTask(new ElicitResult { Action = "accept" }); - }; - - await using var client = await CreateMcpClientForServer(clientOptions); - - await client.CallToolAsync("elicit-tool", - new Dictionary { ["message"] = "test" }, - cancellationToken: TestContext.Current.CancellationToken); - - // The elicitation handler was called, confirming MRTR round-trip occurred - // (InputRequiredResult was sent by server and processed by client). - Assert.True(sawIncompleteResult, "Expected MRTR round-trip with InputRequiredResult"); - _messageTracker.AssertMrtrUsed(); - } -} diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs deleted file mode 100644 index b4e51ff6e..000000000 --- a/tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs +++ /dev/null @@ -1,183 +0,0 @@ -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; -using ModelContextProtocol.Client; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; -using ModelContextProtocol.Tests.Utils; -using System.Collections.Concurrent; -using System.Text.Json.Nodes; - -namespace ModelContextProtocol.Tests.Server; - -/// -/// Tests for session-scoped MRTR resource governance — verifying that outgoing message -/// filters can track and limit MRTR round trips per session. -/// -public class MrtrSessionLimitTests : ClientServerTestBase -{ - /// - /// Tracks the number of pending MRTR flows per session. Incremented when an InputRequiredResult - /// is sent (outgoing filter), decremented when a retry with requestState arrives (incoming filter). - /// - private readonly ConcurrentDictionary _pendingFlowsPerSession = new(); - - /// - /// Records every (sessionId, pendingCount) observation from the outgoing filter, - /// so the test can verify the tracking was correct. - /// - private readonly ConcurrentBag<(string SessionId, int PendingCount)> _observations = []; - - private readonly ServerMessageTracker _messageTracker = new(); - - /// - /// Maximum allowed concurrent MRTR flows per session. If exceeded, the outgoing filter - /// replaces the InputRequiredResult with an error response. - /// - private int _maxFlowsPerSession = int.MaxValue; - - /// - /// Counts how many IncompleteResults were blocked by the per-session limit. - /// - private int _blockedFlowCount; - - public MrtrSessionLimitTests(ITestOutputHelper testOutputHelper) - : base(testOutputHelper, startServer: false) - { - } - - protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) - { - services.Configure(options => - { - options.ProtocolVersion = "DRAFT-2026-v1"; - _messageTracker.AddFilters(options.Filters.Message); - - // Outgoing filter: detect InputRequiredResult responses and track per session. - options.Filters.Message.OutgoingFilters.Add(next => async (context, cancellationToken) => - { - if (context.JsonRpcMessage is JsonRpcResponse response && - response.Result is JsonObject resultObj && - resultObj.TryGetPropertyValue("resultType", out var resultTypeNode) && - resultTypeNode?.GetValue() is "input_required") - { - var sessionId = context.Server.SessionId ?? "unknown"; - var newCount = _pendingFlowsPerSession.AddOrUpdate(sessionId, 1, (_, c) => c + 1); - _observations.Add((sessionId, newCount)); - - // Enforce per-session limit: if exceeded, replace the InputRequiredResult - // with a JSON-RPC error. This prevents the client from receiving the - // InputRequiredResult and starting another retry cycle. - if (newCount > _maxFlowsPerSession) - { - // Undo the increment since we're blocking this flow. - _pendingFlowsPerSession.AddOrUpdate(sessionId, 0, (_, c) => Math.Max(0, c - 1)); - Interlocked.Increment(ref _blockedFlowCount); - - // Replace the outgoing message with a JSON-RPC error. - context.JsonRpcMessage = new JsonRpcError - { - Id = response.Id, - Error = new JsonRpcErrorDetail - { - Code = (int)McpErrorCode.InvalidRequest, - Message = $"Too many pending MRTR flows for this session (limit: {_maxFlowsPerSession}).", - } - }; - } - } - - await next(context, cancellationToken); - }); - - // Incoming filter: detect retries (requests with requestState) and decrement. - options.Filters.Message.IncomingFilters.Add(next => async (context, cancellationToken) => - { - if (context.JsonRpcMessage is JsonRpcRequest request && - request.Params is JsonObject paramsObj && - paramsObj.TryGetPropertyValue("requestState", out var stateNode) && - stateNode is not null) - { - var sessionId = context.Server.SessionId ?? "unknown"; - _pendingFlowsPerSession.AddOrUpdate(sessionId, 0, (_, c) => Math.Max(0, c - 1)); - } - - await next(context, cancellationToken); - }); - }); - - mcpServerBuilder.WithTools([ - McpServerTool.Create( - async (string message, McpServer server, CancellationToken ct) => - { - var result = await server.ElicitAsync(new ElicitRequestParams - { - Message = message, - RequestedSchema = new() - }, ct); - - return $"{result.Action}"; - }, - new McpServerToolCreateOptions - { - Name = "elicit-tool", - Description = "A tool that requests elicitation" - }), - ]); - } - - [Fact] - public async Task OutgoingFilter_TracksIncompleteResultsPerSession() - { - // Verify that an outgoing message filter can observe InputRequiredResult responses - // and track the pending MRTR flow count per session using context.Server.SessionId. - StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; - clientOptions.Handlers.ElicitationHandler = (request, ct) => - new ValueTask(new ElicitResult { Action = "accept" }); - - await using var client = await CreateMcpClientForServer(clientOptions); - - // Call the tool — triggers one MRTR round-trip. - var result = await client.CallToolAsync("elicit-tool", - new Dictionary { ["message"] = "confirm?" }, - cancellationToken: TestContext.Current.CancellationToken); - - Assert.Equal("accept", Assert.IsType(Assert.Single(result.Content)).Text); - - // Verify the filter observed exactly one InputRequiredResult and tracked it. - Assert.Single(_observations); - var (sessionId, pendingCount) = _observations.First(); - Assert.NotNull(sessionId); - Assert.Equal(1, pendingCount); - - // After the retry completed, the count should be back to 0. - Assert.Equal(0, _pendingFlowsPerSession.GetValueOrDefault(sessionId)); - - _messageTracker.AssertMrtrUsed(); - } - - [Fact] - public async Task OutgoingFilter_CanEnforcePerSessionMrtrLimit() - { - // Verify that an outgoing message filter can enforce a per-session MRTR flow limit - // by replacing the InputRequiredResult with a JSON-RPC error when the limit is exceeded. - // Set the limit to 0 so the very first MRTR flow is blocked. - _maxFlowsPerSession = 0; - - StartServer(); - var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }; - clientOptions.Handlers.ElicitationHandler = (request, ct) => - new ValueTask(new ElicitResult { Action = "accept" }); - - await using var client = await CreateMcpClientForServer(clientOptions); - - // The tool call should fail because the outgoing filter blocks the InputRequiredResult. - var ex = await Assert.ThrowsAsync(async () => - await client.CallToolAsync("elicit-tool", - new Dictionary { ["message"] = "confirm?" }, - cancellationToken: TestContext.Current.CancellationToken)); - - Assert.Contains("Too many pending MRTR flows", ex.Message); - Assert.Equal(1, _blockedFlowCount); - } -} diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrTaskIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrTaskIntegrationTests.cs deleted file mode 100644 index 9d1c171b5..000000000 --- a/tests/ModelContextProtocol.Tests/Server/MrtrTaskIntegrationTests.cs +++ /dev/null @@ -1,295 +0,0 @@ -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; -using ModelContextProtocol.Client; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; -using ModelContextProtocol.Tests.Utils; - -namespace ModelContextProtocol.Tests.Server; - -/// -/// Tests for the interaction between MRTR and the Tasks feature — verifying that MRTR-driven -/// tool calls correctly track task status (InputRequired), and that task-based sampling -/// bypasses MRTR interception. -/// -public class MrtrTaskIntegrationTests : ClientServerTestBase -{ - public MrtrTaskIntegrationTests(ITestOutputHelper testOutputHelper) - : base(testOutputHelper, startServer: false) - { - } - - protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) - { - var taskStore = new InMemoryMcpTaskStore(); - services.AddSingleton(taskStore); - services.Configure(options => - { - options.TaskStore = taskStore; - options.ProtocolVersion = "DRAFT-2026-v1"; - }); - - mcpServerBuilder.WithTools([ - McpServerTool.Create( - async (string prompt, McpServer server, CancellationToken ct) => - { - // This tool calls SampleAsync which goes through MRTR when the client supports it. - // When running in a task context, SendRequestWithTaskStatusTrackingAsync should - // set task status to InputRequired while awaiting the sampling result. - var result = await server.SampleAsync(new CreateMessageRequestParams - { - Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = prompt }] }], - MaxTokens = 100 - }, ct); - - return result.Content.OfType().FirstOrDefault()?.Text ?? "No response"; - }, - new McpServerToolCreateOptions - { - Name = "sampling-tool", - Description = "A tool that requests sampling from the client" - }), - McpServerTool.Create( - async (string message, McpServer server, CancellationToken ct) => - { - var result = await server.ElicitAsync(new ElicitRequestParams - { - Message = message, - RequestedSchema = new() - }, ct); - - return $"{result.Action}"; - }, - new McpServerToolCreateOptions - { - Name = "elicitation-tool", - Description = "A tool that requests elicitation from the client" - }), - ]); - } - - [Fact] - public async Task TaskAugmentedToolCall_WithMrtrSampling_TracksInputRequiredStatus() - { - StartServer(); - var taskStore = new InMemoryMcpTaskStore(); - var samplingStarted = new TaskCompletionSource(); - var samplingCanProceed = new TaskCompletionSource(); - - var clientOptions = new McpClientOptions - { - ProtocolVersion = "DRAFT-2026-v1", - TaskStore = taskStore, - Handlers = new McpClientHandlers - { - SamplingHandler = async (request, progress, ct) => - { - samplingStarted.TrySetResult(true); - // Wait until test signals to proceed — this gives us time to check task status - await samplingCanProceed.Task.WaitAsync(ct); - return new CreateMessageResult - { - Content = [new TextContentBlock { Text = "Sampled response" }], - Model = "test-model" - }; - } - } - }; - - await using var client = await CreateMcpClientForServer(clientOptions); - - // Start task-augmented tool call - var mcpTask = await Server.SampleAsTaskAsync( - new CreateMessageRequestParams - { - Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Test" }] }], - MaxTokens = 100 - }, - new McpTaskMetadata(), - TestContext.Current.CancellationToken); - - Assert.NotNull(mcpTask); - Assert.Equal(McpTaskStatus.Working, mcpTask.Status); - - // Wait for sampling handler to be called — this means MRTR resolved the input request - await samplingStarted.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); - - // Let the sampling handler complete - samplingCanProceed.TrySetResult(true); - - // Poll until task completes - McpTask taskStatus; - do - { - await Task.Delay(100, TestContext.Current.CancellationToken); - taskStatus = await Server.GetTaskAsync(mcpTask.TaskId, TestContext.Current.CancellationToken); - } - while (taskStatus.Status == McpTaskStatus.Working || taskStatus.Status == McpTaskStatus.InputRequired); - - Assert.Equal(McpTaskStatus.Completed, taskStatus.Status); - - // Verify the result is correct - var result = await Server.GetTaskResultAsync( - mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken); - - Assert.NotNull(result); - var textContent = Assert.IsType(Assert.Single(result.Content)); - Assert.Equal("Sampled response", textContent.Text); - } - - [Fact] - public async Task TaskAugmentedToolCall_WithMrtrElicitation_CompletesSuccessfully() - { - StartServer(); - var clientOptions = new McpClientOptions - { - ProtocolVersion = "DRAFT-2026-v1", - Handlers = new McpClientHandlers - { - ElicitationHandler = (request, ct) => - { - return new ValueTask(new ElicitResult { Action = "confirm" }); - } - } - }; - - await using var client = await CreateMcpClientForServer(clientOptions); - - // Call the elicitation tool — MRTR resolves the elicitation request via the client handler - var result = await client.CallToolAsync("elicitation-tool", - new Dictionary { ["message"] = "Do you agree?" }, - cancellationToken: TestContext.Current.CancellationToken); - - var content = Assert.Single(result.Content); - Assert.Equal("confirm", Assert.IsType(content).Text); - } - - [Fact] - public async Task SampleAsTaskAsync_BypassesMrtrInterception() - { - // SampleAsTaskAsync sends a request with "task" metadata in the params. - // Even when MRTR context is active, these requests should go over the wire - // (they expect CreateTaskResult, not CreateMessageResult). - StartServer(); - var taskStore = new InMemoryMcpTaskStore(); - - var clientOptions = new McpClientOptions - { - ProtocolVersion = "DRAFT-2026-v1", - TaskStore = taskStore, - Handlers = new McpClientHandlers - { - SamplingHandler = async (request, progress, ct) => - { - await Task.Delay(50, ct); - return new CreateMessageResult - { - Content = [new TextContentBlock { Text = "Task-based response" }], - Model = "test-model" - }; - } - } - }; - - await using var client = await CreateMcpClientForServer(clientOptions); - - // SampleAsTaskAsync should work normally — it sends over the wire, not through MRTR. - var mcpTask = await Server.SampleAsTaskAsync( - new CreateMessageRequestParams - { - Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Hello" }] }], - MaxTokens = 100 - }, - new McpTaskMetadata(), - TestContext.Current.CancellationToken); - - Assert.NotNull(mcpTask); - Assert.NotEmpty(mcpTask.TaskId); - Assert.Equal(McpTaskStatus.Working, mcpTask.Status); - - // Poll until task completes - McpTask taskStatus; - do - { - await Task.Delay(100, TestContext.Current.CancellationToken); - taskStatus = await Server.GetTaskAsync(mcpTask.TaskId, TestContext.Current.CancellationToken); - } - while (taskStatus.Status == McpTaskStatus.Working); - - Assert.Equal(McpTaskStatus.Completed, taskStatus.Status); - - // Retrieve and verify the result - var result = await Server.GetTaskResultAsync( - mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken); - - Assert.NotNull(result); - var textContent = Assert.IsType(Assert.Single(result.Content)); - Assert.Equal("Task-based response", textContent.Text); - } - - [Fact] - public async Task MrtrToolCall_ThenTaskBasedSampling_BothWorkCorrectly() - { - // Verify that MRTR tool calls and task-based sampling can coexist in the same session. - StartServer(); - var taskStore = new InMemoryMcpTaskStore(); - - var clientOptions = new McpClientOptions - { - ProtocolVersion = "DRAFT-2026-v1", - TaskStore = taskStore, - Handlers = new McpClientHandlers - { - SamplingHandler = (request, progress, ct) => - { - var text = request?.Messages[^1].Content.OfType().FirstOrDefault()?.Text; - return new ValueTask(new CreateMessageResult - { - Content = [new TextContentBlock { Text = $"Response: {text}" }], - Model = "test-model" - }); - } - } - }; - - await using var client = await CreateMcpClientForServer(clientOptions); - - // First: MRTR tool call (synchronous sampling inside a tool) - var mrtrResult = await client.CallToolAsync("sampling-tool", - new Dictionary { ["prompt"] = "MRTR test" }, - cancellationToken: TestContext.Current.CancellationToken); - - var mrtrContent = Assert.Single(mrtrResult.Content); - Assert.Equal("Response: MRTR test", Assert.IsType(mrtrContent).Text); - - // Second: Task-based sampling (goes over the wire, bypasses MRTR) - var mcpTask = await Server.SampleAsTaskAsync( - new CreateMessageRequestParams - { - Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Task test" }] }], - MaxTokens = 100 - }, - new McpTaskMetadata(), - TestContext.Current.CancellationToken); - - Assert.NotNull(mcpTask); - - // Poll until task completes - McpTask taskStatus; - do - { - await Task.Delay(100, TestContext.Current.CancellationToken); - taskStatus = await Server.GetTaskAsync(mcpTask.TaskId, TestContext.Current.CancellationToken); - } - while (taskStatus.Status == McpTaskStatus.Working); - - Assert.Equal(McpTaskStatus.Completed, taskStatus.Status); - - var taskResult = await Server.GetTaskResultAsync( - mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken); - - Assert.NotNull(taskResult); - var taskContent = Assert.IsType(Assert.Single(taskResult.Content)); - Assert.Equal("Response: Task test", taskContent.Text); - } -} From 75fe8ee7ccd583ea50a6a96f1d826b1dc9d3d2ab Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Wed, 27 May 2026 15:02:09 -0700 Subject: [PATCH 07/14] Address review feedback: drop typed InputResponse accessors and resolve input requests with WhenAll+CTS Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/elicitation/elicitation.md | 2 +- docs/concepts/mrtr/mrtr.md | 16 +++--- docs/concepts/roots/roots.md | 2 +- docs/concepts/sampling/sampling.md | 2 +- .../Client/McpClientImpl.cs | 33 +++++++++--- .../Protocol/InputResponse.cs | 33 ++++++------ .../Server/McpServerImpl.cs | 52 +++++++++++++++++-- .../MapMcpTests.Mrtr.cs | 28 +++++----- .../Prompts/IncompleteResultPrompts.cs | 2 +- .../Tools/IncompleteResultTools.cs | 8 +-- .../Protocol/MrtrSerializationTests.cs | 17 +++--- 11 files changed, 131 insertions(+), 64 deletions(-) diff --git a/docs/concepts/elicitation/elicitation.md b/docs/concepts/elicitation/elicitation.md index 22cc1bcd3..5a2f27fcf 100644 --- a/docs/concepts/elicitation/elicitation.md +++ b/docs/concepts/elicitation/elicitation.md @@ -188,7 +188,7 @@ public static string ElicitWithMrtr( // On retry, process the client's elicitation response if (context.Params!.InputResponses?.TryGetValue("user_input", out var response) is true) { - var elicitResult = response.ElicitationResult; + var elicitResult = response.Deserialize(InputResponse.ElicitResultTypeInfo); return elicitResult?.Action == "accept" ? $"User accepted: {elicitResult.Content?.FirstOrDefault().Value}" : "User declined."; diff --git a/docs/concepts/mrtr/mrtr.md b/docs/concepts/mrtr/mrtr.md index 607a322bc..e84e6f596 100644 --- a/docs/concepts/mrtr/mrtr.md +++ b/docs/concepts/mrtr/mrtr.md @@ -96,7 +96,7 @@ public static string AnswerTool( // On retry, process the client's responses if (requestState is not null && inputResponses is not null) { - var elicitResult = inputResponses["user_answer"].ElicitationResult; + var elicitResult = inputResponses["user_answer"].Deserialize(InputResponse.ElicitResultTypeInfo); return $"You answered: {elicitResult?.Content?.FirstOrDefault().Value}"; } @@ -135,11 +135,11 @@ When the client retries a tool call, the retry data is available on the request - — a dictionary of client responses keyed by the same keys used in `inputRequests`. - — the opaque state string echoed back by the client. -Each `InputResponse` has typed accessors for the response type: +Use with the `JsonTypeInfo` matching the response type. The expected type follows from the matching in the original `inputRequests` map — there is no on-the-wire discriminator. -- `ElicitationResult` — the result of an elicitation request. -- `SamplingResult` — the result of a sampling request. -- `RootsResult` — the result of a roots list request. +- Elicitation — `response.Deserialize(InputResponse.ElicitResultTypeInfo)` +- Sampling — `response.Deserialize(InputResponse.SamplingResultTypeInfo)` +- Roots list — `response.Deserialize(InputResponse.RootsResultTypeInfo)` ### Load shedding with requestState-only responses @@ -191,14 +191,14 @@ public static string WizardTool( if (requestState == "step-2" && inputResponses is not null) { - var name = inputResponses["name"].ElicitationResult?.Content?.FirstOrDefault().Value; - var age = inputResponses["age"].ElicitationResult?.Content?.FirstOrDefault().Value; + var name = inputResponses["name"].Deserialize(InputResponse.ElicitResultTypeInfo)?.Content?.FirstOrDefault().Value; + var age = inputResponses["age"].Deserialize(InputResponse.ElicitResultTypeInfo)?.Content?.FirstOrDefault().Value; return $"Welcome, {name}! You are {age} years old."; } if (requestState == "step-1" && inputResponses is not null) { - var name = inputResponses["name"].ElicitationResult?.Content?.FirstOrDefault().Value; + var name = inputResponses["name"].Deserialize(InputResponse.ElicitResultTypeInfo)?.Content?.FirstOrDefault().Value; // Second round — ask for age throw new InputRequiredException( diff --git a/docs/concepts/roots/roots.md b/docs/concepts/roots/roots.md index 2525b9b65..fb89f05f1 100644 --- a/docs/concepts/roots/roots.md +++ b/docs/concepts/roots/roots.md @@ -122,7 +122,7 @@ public static string ListRootsWithMrtr( // On retry, process the client's roots response if (context.Params!.InputResponses?.TryGetValue("get_roots", out var response) is true) { - var roots = response.RootsResult?.Roots ?? []; + var roots = response.Deserialize(InputResponse.RootsResultTypeInfo)?.Roots ?? []; return $"Found {roots.Count} roots: {string.Join(", ", roots.Select(r => r.Uri))}"; } diff --git a/docs/concepts/sampling/sampling.md b/docs/concepts/sampling/sampling.md index 9387b3730..e8f489be1 100644 --- a/docs/concepts/sampling/sampling.md +++ b/docs/concepts/sampling/sampling.md @@ -139,7 +139,7 @@ public static string SampleWithMrtr( // On retry, process the client's sampling response if (context.Params!.InputResponses?.TryGetValue("llm_call", out var response) is true) { - var text = response.SamplingResult?.Content + var text = response.Deserialize(InputResponse.SamplingResultTypeInfo)?.Content .OfType().FirstOrDefault()?.Text; return $"LLM said: {text}"; } diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index 55e946902..d809003ec 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -566,20 +566,41 @@ private async ValueTask> ResolveInputRequests IDictionary inputRequests, CancellationToken cancellationToken) { - var responses = new Dictionary(inputRequests.Count); + // Resolve all input requests concurrently. If any fails, cancel the rest so user-facing + // handlers (sampling/elicitation prompts) don't keep running for a request whose caller + // has already given up, and ensure exceptions from late-completing tasks are observed. + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - // Resolve all input requests concurrently - var tasks = new List<(string Key, Task Task)>(inputRequests.Count); + var keyed = new (string Key, Task Task)[inputRequests.Count]; + int i = 0; foreach (var kvp in inputRequests) { - tasks.Add((kvp.Key, ResolveInputRequestAsync(kvp.Value, cancellationToken))); + keyed[i++] = (kvp.Key, ResolveInputRequestAsync(kvp.Value, linkedCts.Token)); } - foreach (var entry in tasks) + try { - responses[entry.Key] = await entry.Task.ConfigureAwait(false); + await Task.WhenAll(Array.ConvertAll(keyed, k => k.Task)).ConfigureAwait(false); + } + catch + { + linkedCts.Cancel(); + try + { + await Task.WhenAll(Array.ConvertAll(keyed, k => k.Task)).ConfigureAwait(false); + } + catch + { + // Observed; the original exception is the one we want to surface. + } + throw; } + var responses = new Dictionary(keyed.Length); + foreach (var (key, task) in keyed) + { + responses[key] = task.Result; + } return responses; } diff --git a/src/ModelContextProtocol.Core/Protocol/InputResponse.cs b/src/ModelContextProtocol.Core/Protocol/InputResponse.cs index e6db80d3a..b4294a764 100644 --- a/src/ModelContextProtocol.Core/Protocol/InputResponse.cs +++ b/src/ModelContextProtocol.Core/Protocol/InputResponse.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.Protocol; @@ -28,7 +29,10 @@ public sealed class InputResponse /// Gets or sets the raw JSON element representing the response. /// /// - /// Use or the typed factory methods to work with concrete response types. + /// Use with the JsonTypeInfo<T> matching the + /// associated — for elicitation, sampling, or roots see + /// , , and + /// . /// [JsonIgnore] public JsonElement RawValue { get; set; } @@ -43,28 +47,25 @@ public sealed class InputResponse JsonSerializer.Deserialize(RawValue, typeInfo); /// - /// Gets the response as a . + /// Gets the for , suitable for use with + /// when the corresponding is + /// . /// - /// The deserialized sampling result, or if deserialization fails. - [JsonIgnore] - public CreateMessageResult? SamplingResult => - JsonSerializer.Deserialize(RawValue, McpJsonUtilities.JsonContext.Default.CreateMessageResult); + public static JsonTypeInfo ElicitResultTypeInfo => McpJsonUtilities.JsonContext.Default.ElicitResult; /// - /// Gets the response as an . + /// Gets the for , suitable for use with + /// when the corresponding is + /// . /// - /// The deserialized elicitation result, or if deserialization fails. - [JsonIgnore] - public ElicitResult? ElicitationResult => - JsonSerializer.Deserialize(RawValue, McpJsonUtilities.JsonContext.Default.ElicitResult); + public static JsonTypeInfo SamplingResultTypeInfo => McpJsonUtilities.JsonContext.Default.CreateMessageResult; /// - /// Gets the response as a . + /// Gets the for , suitable for use with + /// when the corresponding is + /// . /// - /// The deserialized roots list result, or if deserialization fails. - [JsonIgnore] - public ListRootsResult? RootsResult => - JsonSerializer.Deserialize(RawValue, McpJsonUtilities.JsonContext.Default.ListRootsResult); + public static JsonTypeInfo RootsResultTypeInfo => McpJsonUtilities.JsonContext.Default.ListRootsResult; /// /// Creates an from a . diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 3d7222ee5..ffc440bd1 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -1217,11 +1217,7 @@ internal bool IsLowLevelMrtrAvailable() => } // Resolve each input request by sending the corresponding JSON-RPC call to the client. - var inputResponses = new Dictionary(inputRequests.Count); - foreach (var kvp in inputRequests) - { - inputResponses[kvp.Key] = await ResolveInputRequestAsync(kvp.Value, cancellationToken).ConfigureAwait(false); - } + var inputResponses = await ResolveInputRequestsAsync(inputRequests, cancellationToken).ConfigureAwait(false); // Reconstruct request params with inputResponses and requestState for the retry. var paramsObj = request.Params?.DeepClone() as JsonObject ?? new JsonObject(); @@ -1244,6 +1240,52 @@ internal bool IsLowLevelMrtrAvailable() => } } + /// + /// Resolves a batch of MRTR input requests concurrently by dispatching each as a standard + /// JSON-RPC request to the client. On the first failure all remaining handlers are cancelled + /// so user-facing flows (sampling/elicitation prompts) don't keep running once the caller has + /// given up, and exceptions from late-completing tasks are observed before the original + /// exception is rethrown. + /// + private async Task> ResolveInputRequestsAsync( + IDictionary inputRequests, + CancellationToken cancellationToken) + { + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + var keyed = new (string Key, Task Task)[inputRequests.Count]; + int i = 0; + foreach (var kvp in inputRequests) + { + keyed[i++] = (kvp.Key, ResolveInputRequestAsync(kvp.Value, linkedCts.Token)); + } + + try + { + await Task.WhenAll(Array.ConvertAll(keyed, k => k.Task)).ConfigureAwait(false); + } + catch + { + linkedCts.Cancel(); + try + { + await Task.WhenAll(Array.ConvertAll(keyed, k => k.Task)).ConfigureAwait(false); + } + catch + { + // Observed; the original exception is the one we want to surface. + } + throw; + } + + var responses = new Dictionary(keyed.Length); + foreach (var (key, task) in keyed) + { + responses[key] = task.Result; + } + return responses; + } + /// /// Resolves a single MRTR by dispatching it as a standard JSON-RPC /// request to the client. This is the server-side mirror of the client's input resolution logic, diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs index f024777f0..15d9dc84e 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs @@ -91,7 +91,7 @@ private static async Task MrtrMixed(McpServer server, RequestContext MrtrMixed(McpServer server, RequestContext().FirstOrDefault()?.Text ?? ""; - var root = responses["roots"].RootsResult?.Roots?.FirstOrDefault()?.Name ?? ""; + var root = responses["roots"].Deserialize(InputResponse.RootsResultTypeInfo)?.Roots?.FirstOrDefault()?.Name ?? ""; // Exception API: single elicitation with requestState throw new InputRequiredException( @@ -305,7 +305,7 @@ private static string MrtrElicit(RequestContext context) if (context.Params!.InputResponses is { } responses && responses.TryGetValue("user_input", out var response)) { - return $"elicit-ok:{response.ElicitationResult?.Action}"; + return $"elicit-ok:{response.Deserialize(InputResponse.ElicitResultTypeInfo)?.Action}"; } throw new InputRequiredException( @@ -329,7 +329,7 @@ public async Task Mrtr_LowLevel_Roots_CompletesViaMrtr() if (context.Params!.InputResponses is { } responses && responses.TryGetValue("roots", out var response)) { - var roots = response.RootsResult?.Roots; + var roots = response.Deserialize(InputResponse.RootsResultTypeInfo)?.Roots; return $"roots-ok:{string.Join(",", roots?.Select(r => r.Uri) ?? [])}"; } @@ -363,13 +363,13 @@ private static string MrtrMulti(RequestContext context) if (requestState == "round-2" && inputResponses is not null) { - var greeting = inputResponses["greeting"].ElicitationResult?.Action; + var greeting = inputResponses["greeting"].Deserialize(InputResponse.ElicitResultTypeInfo)?.Action; return $"multi-done:greeting={greeting}"; } if (requestState == "round-1" && inputResponses is not null) { - var name = inputResponses["name"].ElicitationResult?.Content?.FirstOrDefault().Value; + var name = inputResponses["name"].Deserialize(InputResponse.ElicitResultTypeInfo)?.Content?.FirstOrDefault().Value; throw new InputRequiredException( inputRequests: new Dictionary { @@ -475,11 +475,11 @@ private static string MrtrConcurrentThree(RequestContext responses.ContainsKey("sample") && responses.ContainsKey("roots")) { - var elicitAction = responses["elicit"].ElicitationResult?.Action; - var sampleText = responses["sample"].SamplingResult? + var elicitAction = responses["elicit"].Deserialize(InputResponse.ElicitResultTypeInfo)?.Action; + var sampleText = responses["sample"].Deserialize(InputResponse.SamplingResultTypeInfo)? .Content.OfType().FirstOrDefault()?.Text; var rootUris = string.Join(",", - responses["roots"].RootsResult?.Roots.Select(r => r.Uri) ?? []); + responses["roots"].Deserialize(InputResponse.RootsResultTypeInfo)?.Roots.Select(r => r.Uri) ?? []); return $"all-ok:elicit={elicitAction},sample={sampleText},roots={rootUris}"; } @@ -596,7 +596,7 @@ public async Task Mrtr_Backcompat_Roots_ResolvedViaLegacyJsonRpc() if (context.Params!.InputResponses is { } responses && responses.TryGetValue("roots", out var response)) { - var roots = response.RootsResult?.Roots; + var roots = response.Deserialize(InputResponse.RootsResultTypeInfo)?.Roots; return $"roots-ok:{roots?.FirstOrDefault()?.Name}"; } @@ -633,8 +633,8 @@ public async Task Mrtr_Backcompat_MultipleInputRequests_ResolvedViaLegacyJsonRpc responses.TryGetValue("confirm", out var elicitResponse) && responses.TryGetValue("summarize", out var sampleResponse)) { - var action = elicitResponse.ElicitationResult?.Action; - var text = sampleResponse.SamplingResult?.Content.OfType().FirstOrDefault()?.Text; + var action = elicitResponse.Deserialize(InputResponse.ElicitResultTypeInfo)?.Action; + var text = sampleResponse.Deserialize(InputResponse.SamplingResultTypeInfo)?.Content.OfType().FirstOrDefault()?.Text; return $"both:{action}:{text}"; } diff --git a/tests/ModelContextProtocol.ConformanceServer/Prompts/IncompleteResultPrompts.cs b/tests/ModelContextProtocol.ConformanceServer/Prompts/IncompleteResultPrompts.cs index 1e54ab645..abbd60bb5 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Prompts/IncompleteResultPrompts.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Prompts/IncompleteResultPrompts.cs @@ -23,7 +23,7 @@ public static GetPromptResult IncompleteResultPrompt(RequestContext().FirstOrDefault()?.Text ?? "(no text)"; + var text = response.Deserialize(InputResponse.SamplingResultTypeInfo)?.Content?.OfType().FirstOrDefault()?.Text ?? "(no text)"; return TextResult($"Sampling said: {text}"); } @@ -88,7 +88,7 @@ public static CallToolResult ToolWithListRoots(RequestContext(json, McpJsonUtilities.DefaultOptions); Assert.NotNull(deserialized); - Assert.NotNull(deserialized.SamplingResult); - Assert.Equal("test-model", deserialized.SamplingResult.Model); + var sampling = deserialized.Deserialize(InputResponse.SamplingResultTypeInfo); + Assert.NotNull(sampling); + Assert.Equal("test-model", sampling.Model); } [Fact] @@ -197,8 +198,9 @@ public static void InputResponse_FromElicitResult_RoundTrip() var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); Assert.NotNull(deserialized); - Assert.NotNull(deserialized.ElicitationResult); - Assert.Equal("confirm", deserialized.ElicitationResult.Action); + var elicit = deserialized.Deserialize(InputResponse.ElicitResultTypeInfo); + Assert.NotNull(elicit); + Assert.Equal("confirm", elicit.Action); } [Fact] @@ -215,9 +217,10 @@ public static void InputResponse_FromRootsResult_RoundTrip() var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); Assert.NotNull(deserialized); - Assert.NotNull(deserialized.RootsResult); - Assert.Single(deserialized.RootsResult.Roots); - Assert.Equal("file:///test", deserialized.RootsResult.Roots[0].Uri); + var roots = deserialized.Deserialize(InputResponse.RootsResultTypeInfo); + Assert.NotNull(roots); + Assert.Single(roots.Roots); + Assert.Equal("file:///test", roots.Roots[0].Uri); } [Fact] From 5db1781f6e00c99827b407a30b7a4581a41504af Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Wed, 27 May 2026 17:46:31 -0700 Subject: [PATCH 08/14] Gate high-level server-to-client requests on stateless mode, not draft 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> --- docs/concepts/elicitation/elicitation.md | 6 +- docs/concepts/mrtr/mrtr.md | 24 +-- docs/concepts/roots/roots.md | 6 +- docs/concepts/sampling/sampling.md | 6 +- .../Protocol/InputResponse.cs | 10 +- .../Server/McpServer.Methods.cs | 27 ---- .../MapMcpTests.Mrtr.cs | 28 ++-- .../MrtrProtocolTests.cs | 31 ---- .../Prompts/IncompleteResultPrompts.cs | 2 +- .../Tools/IncompleteResultTools.cs | 8 +- .../Protocol/MrtrSerializationTests.cs | 6 +- .../Server/DraftProtocolBackcompatTests.cs | 151 ++++++++++++++++++ .../Server/DraftProtocolGuardTests.cs | 109 ------------- 13 files changed, 199 insertions(+), 215 deletions(-) create mode 100644 tests/ModelContextProtocol.Tests/Server/DraftProtocolBackcompatTests.cs delete mode 100644 tests/ModelContextProtocol.Tests/Server/DraftProtocolGuardTests.cs diff --git a/docs/concepts/elicitation/elicitation.md b/docs/concepts/elicitation/elicitation.md index 5a2f27fcf..78782bfbb 100644 --- a/docs/concepts/elicitation/elicitation.md +++ b/docs/concepts/elicitation/elicitation.md @@ -172,10 +172,10 @@ Here's an example implementation of how a console application might handle elici ### 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 only supported way to ask the user for input from a server handler is to throw and let the SDK emit an on the wire. +[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 and let the SDK emit an on the wire. > [!IMPORTANT] -> Calling `ElicitAsync` after negotiating `DRAFT-2026-v1` throws `InvalidOperationException`. Use `InputRequiredException` from your tool/prompt handler instead — it works under both the current protocol (resolved via legacy JSON-RPC + retry on stateful sessions) and the draft protocol (wire-format `InputRequiredResult`, no server-side handler state required). +> `ElicitAsync` throws `InvalidOperationException("Elicitation is not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `DRAFT-2026-v1` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `elicitation/create` request flow. For code that needs to run on stateless servers — including all `DRAFT-2026-v1` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. For example: @@ -188,7 +188,7 @@ public static string ElicitWithMrtr( // On retry, process the client's elicitation response if (context.Params!.InputResponses?.TryGetValue("user_input", out var response) is true) { - var elicitResult = response.Deserialize(InputResponse.ElicitResultTypeInfo); + var elicitResult = response.Deserialize(InputResponse.ElicitResultJsonTypeInfo); return elicitResult?.Action == "accept" ? $"User accepted: {elicitResult.Content?.FirstOrDefault().Value}" : "User declined."; diff --git a/docs/concepts/mrtr/mrtr.md b/docs/concepts/mrtr/mrtr.md index e84e6f596..1d1ebce32 100644 --- a/docs/concepts/mrtr/mrtr.md +++ b/docs/concepts/mrtr/mrtr.md @@ -48,7 +48,7 @@ var clientOptions = new McpClientOptions }; ``` -Under `DRAFT-2026-v1`, MRTR is the **only** way to obtain client input from a server handler. The legacy server-to-client `elicitation/create`, `sampling/createMessage`, and `roots/list` request methods are removed; calling , , or on a server that negotiated `DRAFT-2026-v1` throws `InvalidOperationException`. Tools that need client input must throw instead. +Under `DRAFT-2026-v1`, MRTR is the recommended way to obtain client input from a server handler. The spec removes the legacy server-to-client `elicitation/create`, `sampling/createMessage`, and `roots/list` request methods, so any code that needs to work on a `DRAFT-2026-v1` Streamable HTTP server (which will be stateless-only in a future revision) must use `InputRequiredException` rather than , , or . The legacy methods still work on stateful sessions — that's how stdio servers keep working under draft today — but they throw `InvalidOperationException("X is not supported in stateless mode.")` on any stateless session, current or draft. Under the current protocol revision (`2025-06-18` and earlier), `InputRequiredException` is still supported in stateful sessions via a backward-compatibility resolver — see [Compatibility](#compatibility) below. @@ -96,7 +96,7 @@ public static string AnswerTool( // On retry, process the client's responses if (requestState is not null && inputResponses is not null) { - var elicitResult = inputResponses["user_answer"].Deserialize(InputResponse.ElicitResultTypeInfo); + var elicitResult = inputResponses["user_answer"].Deserialize(InputResponse.ElicitResultJsonTypeInfo); return $"You answered: {elicitResult?.Content?.FirstOrDefault().Value}"; } @@ -137,9 +137,9 @@ When the client retries a tool call, the retry data is available on the request Use with the `JsonTypeInfo` matching the response type. The expected type follows from the matching in the original `inputRequests` map — there is no on-the-wire discriminator. -- Elicitation — `response.Deserialize(InputResponse.ElicitResultTypeInfo)` -- Sampling — `response.Deserialize(InputResponse.SamplingResultTypeInfo)` -- Roots list — `response.Deserialize(InputResponse.RootsResultTypeInfo)` +- Elicitation — `response.Deserialize(InputResponse.ElicitResultJsonTypeInfo)` +- Sampling — `response.Deserialize(InputResponse.CreateMessageResultJsonTypeInfo)` +- Roots list — `response.Deserialize(InputResponse.ListRootsResultJsonTypeInfo)` ### Load shedding with requestState-only responses @@ -191,14 +191,14 @@ public static string WizardTool( if (requestState == "step-2" && inputResponses is not null) { - var name = inputResponses["name"].Deserialize(InputResponse.ElicitResultTypeInfo)?.Content?.FirstOrDefault().Value; - var age = inputResponses["age"].Deserialize(InputResponse.ElicitResultTypeInfo)?.Content?.FirstOrDefault().Value; + var name = inputResponses["name"].Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Content?.FirstOrDefault().Value; + var age = inputResponses["age"].Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Content?.FirstOrDefault().Value; return $"Welcome, {name}! You are {age} years old."; } if (requestState == "step-1" && inputResponses is not null) { - var name = inputResponses["name"].Deserialize(InputResponse.ElicitResultTypeInfo)?.Content?.FirstOrDefault().Value; + var name = inputResponses["name"].Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Content?.FirstOrDefault().Value; // Second round — ask for age throw new InputRequiredException( @@ -278,14 +278,14 @@ The SDK supports `InputRequiredException` across two protocol revisions and two > [!NOTE] > The backcompat resolver is intentionally limited to 10 retry rounds. Tools that need more rounds should require `DRAFT-2026-v1` (check `IsMrtrSupported`). -### Why `ElicitAsync` / `SampleAsync` / `RequestRootsAsync` throw under draft +### Why `ElicitAsync` / `SampleAsync` / `RequestRootsAsync` throw on stateless servers -The `DRAFT-2026-v1` revision removes the server-to-client `elicitation/create`, `sampling/createMessage`, and `roots/list` request methods entirely. Servers cannot use those request methods because clients no longer advertise the corresponding capabilities or implement handlers for them. The SDK fails fast with a clear `InvalidOperationException` so you can fix the call site before it manifests as a wire-level error. +`ElicitAsync` / `SampleAsync` / `RequestRootsAsync` issue a JSON-RPC request to the client and wait for the response on the same session. Stateless servers don't have a persistent session to wait on, so the SDK fails fast with `InvalidOperationException("X is not supported in stateless mode.")` (the check is `McpServer.ClientCapabilities is null`, which is the SDK's proxy for stateless). -Under the current protocol revision (`2025-06-18` and earlier), these methods continue to work normally and are the recommended way to do simple, one-shot client interactions. `InputRequiredException` is the way to write tools that work the same on both revisions. +Under the current protocol revision (`2025-06-18` and earlier), stdio and stateful Streamable HTTP keep `ClientCapabilities` populated, so the legacy methods work normally and remain the recommended way to do one-shot client interactions. Under `DRAFT-2026-v1`, the spec removes those request methods from Streamable HTTP entirely; the SDK still allows the legacy methods on draft stdio sessions because stdio is implicitly single-process / stateful and the client handler is wired up regardless of negotiated revision. `InputRequiredException` is the way to write tools that work on every supported configuration. ### Future direction -The `DRAFT-2026-v1` revision is moving toward a stateless-only model: `Mcp-Session-Id` is being removed, and Streamable HTTP servers will run statelessly by default under the draft revision. When that happens, the `Stateful` row of the compatibility matrix above collapses into the `Stateless` row, and `InputRequiredException` becomes uniformly native across both. The current-protocol resolver path will remain for backward compatibility with older clients and stateful servers. +The `DRAFT-2026-v1` revision is moving toward a stateless-only model: `Mcp-Session-Id` is being removed, and Streamable HTTP servers will run statelessly by default under the draft revision. When that lands, the `Stateful` row for `DRAFT-2026-v1` in the compatibility matrix above collapses into the `Stateless` row (Streamable HTTP under draft becomes stateless-only), and `InputRequiredException` becomes uniformly required for non-stdio servers. The current-protocol resolver path will remain for backward compatibility with older clients and stateful servers. This work is a follow-up to the present PR. diff --git a/docs/concepts/roots/roots.md b/docs/concepts/roots/roots.md index fb89f05f1..213d317c0 100644 --- a/docs/concepts/roots/roots.md +++ b/docs/concepts/roots/roots.md @@ -106,10 +106,10 @@ server.RegisterNotificationHandler( ### 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 `roots/list` request method is removed; the only supported way to ask the client for its roots from a server handler is to throw and let the SDK emit an on the wire. +[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 `roots/list` request method is removed; the recommended way to ask the client for its roots from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> Calling `RequestRootsAsync` after negotiating `DRAFT-2026-v1` throws `InvalidOperationException`. Use `InputRequiredException` from your tool/prompt handler instead — it works under both the current protocol (resolved via legacy JSON-RPC + retry on stateful sessions) and the draft protocol (wire-format `InputRequiredResult`, no server-side handler state required). +> `RequestRootsAsync` throws `InvalidOperationException("Roots are not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `DRAFT-2026-v1` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `roots/list` request flow. For code that needs to run on stateless servers — including all `DRAFT-2026-v1` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. For example: @@ -122,7 +122,7 @@ public static string ListRootsWithMrtr( // On retry, process the client's roots response if (context.Params!.InputResponses?.TryGetValue("get_roots", out var response) is true) { - var roots = response.Deserialize(InputResponse.RootsResultTypeInfo)?.Roots ?? []; + var roots = response.Deserialize(InputResponse.ListRootsResultJsonTypeInfo)?.Roots ?? []; return $"Found {roots.Count} roots: {string.Join(", ", roots.Select(r => r.Uri))}"; } diff --git a/docs/concepts/sampling/sampling.md b/docs/concepts/sampling/sampling.md index e8f489be1..bac6ed5ab 100644 --- a/docs/concepts/sampling/sampling.md +++ b/docs/concepts/sampling/sampling.md @@ -123,10 +123,10 @@ Sampling requires the client to advertise the `sampling` capability. This is han ### 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 `sampling/createMessage` request method is removed; the only supported way to ask the client to sample from a server handler is to throw and let the SDK emit an on the wire. +[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 `sampling/createMessage` request method is removed; the recommended way to ask the client to sample from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> Calling `SampleAsync` or `AsSamplingChatClient` after negotiating `DRAFT-2026-v1` throws `InvalidOperationException`. Use `InputRequiredException` from your tool/prompt handler instead — it works under both the current protocol (resolved via legacy JSON-RPC + retry on stateful sessions) and the draft protocol (wire-format `InputRequiredResult`, no server-side handler state required). +> `SampleAsync` and `AsSamplingChatClient` throw `InvalidOperationException("Sampling is not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `DRAFT-2026-v1` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `sampling/createMessage` request flow. For code that needs to run on stateless servers — including all `DRAFT-2026-v1` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. For example: @@ -139,7 +139,7 @@ public static string SampleWithMrtr( // On retry, process the client's sampling response if (context.Params!.InputResponses?.TryGetValue("llm_call", out var response) is true) { - var text = response.Deserialize(InputResponse.SamplingResultTypeInfo)?.Content + var text = response.Deserialize(InputResponse.CreateMessageResultJsonTypeInfo)?.Content .OfType().FirstOrDefault()?.Text; return $"LLM said: {text}"; } diff --git a/src/ModelContextProtocol.Core/Protocol/InputResponse.cs b/src/ModelContextProtocol.Core/Protocol/InputResponse.cs index b4294a764..79ac22dc9 100644 --- a/src/ModelContextProtocol.Core/Protocol/InputResponse.cs +++ b/src/ModelContextProtocol.Core/Protocol/InputResponse.cs @@ -31,8 +31,8 @@ public sealed class InputResponse /// /// Use with the JsonTypeInfo<T> matching the /// associated — for elicitation, sampling, or roots see - /// , , and - /// . + /// , , and + /// . /// [JsonIgnore] public JsonElement RawValue { get; set; } @@ -51,21 +51,21 @@ public sealed class InputResponse /// when the corresponding is /// . /// - public static JsonTypeInfo ElicitResultTypeInfo => McpJsonUtilities.JsonContext.Default.ElicitResult; + public static JsonTypeInfo ElicitResultJsonTypeInfo => McpJsonUtilities.JsonContext.Default.ElicitResult; /// /// Gets the for , suitable for use with /// when the corresponding is /// . /// - public static JsonTypeInfo SamplingResultTypeInfo => McpJsonUtilities.JsonContext.Default.CreateMessageResult; + public static JsonTypeInfo CreateMessageResultJsonTypeInfo => McpJsonUtilities.JsonContext.Default.CreateMessageResult; /// /// Gets the for , suitable for use with /// when the corresponding is /// . /// - public static JsonTypeInfo RootsResultTypeInfo => McpJsonUtilities.JsonContext.Default.ListRootsResult; + public static JsonTypeInfo ListRootsResultJsonTypeInfo => McpJsonUtilities.JsonContext.Default.ListRootsResult; /// /// Creates an from a . diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs index 413639273..3caaca5a6 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs @@ -63,7 +63,6 @@ public async ValueTask SampleAsync( CancellationToken cancellationToken = default) { Throw.IfNull(requestParams); - ThrowIfDraftProtocol(nameof(SampleAsync), "Throw 'InputRequiredException' from your handler with 'InputRequest.ForSampling(...)' instead."); ThrowIfSamplingUnsupported(); return await SendRequestWithTaskStatusTrackingAsync( @@ -97,7 +96,6 @@ public async ValueTask SampleAsTaskAsync( { Throw.IfNull(requestParams); Throw.IfNull(taskMetadata); - ThrowIfDraftProtocol(nameof(SampleAsTaskAsync), "Task-augmented sampling via the legacy 'sampling/createMessage' method is removed under draft. Track https://github.com/modelcontextprotocol/csharp-sdk for the upcoming 'tasks/input_response' MRTR-task pattern."); ThrowIfSamplingUnsupported(); ThrowIfTasksUnsupportedForSampling(); @@ -129,7 +127,6 @@ public async Task SampleAsync( IEnumerable messages, ChatOptions? chatOptions = default, JsonSerializerOptions? serializerOptions = null, CancellationToken cancellationToken = default) { Throw.IfNull(messages); - ThrowIfDraftProtocol(nameof(SampleAsync), "Throw 'InputRequiredException' from your handler with 'InputRequest.ForSampling(...)' instead."); serializerOptions ??= McpJsonUtilities.DefaultOptions; @@ -257,7 +254,6 @@ public async Task SampleAsync( /// The client does not support sampling. public IChatClient AsSamplingChatClient(JsonSerializerOptions? serializerOptions = null) { - ThrowIfDraftProtocol(nameof(AsSamplingChatClient), "Throw 'InputRequiredException' from your handler with 'InputRequest.ForSampling(...)' instead."); ThrowIfSamplingUnsupported(); return new SamplingChatClient(this, serializerOptions ?? McpJsonUtilities.DefaultOptions); @@ -282,7 +278,6 @@ public ValueTask RequestRootsAsync( CancellationToken cancellationToken = default) { Throw.IfNull(requestParams); - ThrowIfDraftProtocol(nameof(RequestRootsAsync), "Throw 'InputRequiredException' from your handler with 'InputRequest.ForRootsList(...)' instead."); ThrowIfRootsUnsupported(); return SendRequestAsync( @@ -312,7 +307,6 @@ public async ValueTask ElicitAsync( CancellationToken cancellationToken = default) { Throw.IfNull(requestParams); - ThrowIfDraftProtocol(nameof(ElicitAsync), "Throw 'InputRequiredException' from your handler with 'InputRequest.ForElicitation(...)' instead."); ThrowIfElicitationUnsupported(requestParams); var result = await SendRequestWithTaskStatusTrackingAsync( @@ -348,7 +342,6 @@ public async ValueTask ElicitAsTaskAsync( { Throw.IfNull(requestParams); Throw.IfNull(taskMetadata); - ThrowIfDraftProtocol(nameof(ElicitAsTaskAsync), "Task-augmented elicitation via the legacy 'elicitation/create' method is removed under draft. Track https://github.com/modelcontextprotocol/csharp-sdk for the upcoming 'tasks/input_response' MRTR-task pattern."); ThrowIfElicitationUnsupported(requestParams); ThrowIfTasksUnsupportedForElicitation(); @@ -683,7 +676,6 @@ public async ValueTask> ElicitAsync( CancellationToken cancellationToken = default) { Throw.IfNullOrWhiteSpace(message); - ThrowIfDraftProtocol(nameof(ElicitAsync), "Throw 'InputRequiredException' from your handler with 'InputRequest.ForElicitation(...)' instead."); var serializerOptions = options?.JsonSerializerOptions ?? McpJsonUtilities.DefaultOptions; serializerOptions.MakeReadOnly(); @@ -846,25 +838,6 @@ private static bool TryValidateElicitationPrimitiveSchema(JsonElement schema, Ty return true; } - /// - /// SEP-2322 (MRTR) removes the server→client elicitation/create, - /// sampling/createMessage, and roots/list request methods from the - /// DRAFT-2026-v1 protocol revision. The only supported way to obtain client - /// input from a server handler under draft is to throw - /// and let the SDK - /// emit an on the wire. - /// - private void ThrowIfDraftProtocol(string memberName, string replacement) - { - if (NegotiatedProtocolVersion == McpSessionHandler.DraftProtocolVersion) - { - throw new InvalidOperationException( - $"'{memberName}' is not supported after negotiating MCP protocol version '{McpSessionHandler.DraftProtocolVersion}'. " + - $"The draft protocol removes the corresponding server-to-client request method per SEP-2322 (Multi Round-Trip Requests). " + - $"{replacement} See docs/concepts/mrtr/mrtr.md for details."); - } - } - private void ThrowIfSamplingUnsupported() { if (ClientCapabilities?.Sampling is null) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs index 15d9dc84e..1d81723fb 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs @@ -91,7 +91,7 @@ private static async Task MrtrMixed(McpServer server, RequestContext MrtrMixed(McpServer server, RequestContext().FirstOrDefault()?.Text ?? ""; - var root = responses["roots"].Deserialize(InputResponse.RootsResultTypeInfo)?.Roots?.FirstOrDefault()?.Name ?? ""; + var root = responses["roots"].Deserialize(InputResponse.ListRootsResultJsonTypeInfo)?.Roots?.FirstOrDefault()?.Name ?? ""; // Exception API: single elicitation with requestState throw new InputRequiredException( @@ -305,7 +305,7 @@ private static string MrtrElicit(RequestContext context) if (context.Params!.InputResponses is { } responses && responses.TryGetValue("user_input", out var response)) { - return $"elicit-ok:{response.Deserialize(InputResponse.ElicitResultTypeInfo)?.Action}"; + return $"elicit-ok:{response.Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Action}"; } throw new InputRequiredException( @@ -329,7 +329,7 @@ public async Task Mrtr_LowLevel_Roots_CompletesViaMrtr() if (context.Params!.InputResponses is { } responses && responses.TryGetValue("roots", out var response)) { - var roots = response.Deserialize(InputResponse.RootsResultTypeInfo)?.Roots; + var roots = response.Deserialize(InputResponse.ListRootsResultJsonTypeInfo)?.Roots; return $"roots-ok:{string.Join(",", roots?.Select(r => r.Uri) ?? [])}"; } @@ -363,13 +363,13 @@ private static string MrtrMulti(RequestContext context) if (requestState == "round-2" && inputResponses is not null) { - var greeting = inputResponses["greeting"].Deserialize(InputResponse.ElicitResultTypeInfo)?.Action; + var greeting = inputResponses["greeting"].Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Action; return $"multi-done:greeting={greeting}"; } if (requestState == "round-1" && inputResponses is not null) { - var name = inputResponses["name"].Deserialize(InputResponse.ElicitResultTypeInfo)?.Content?.FirstOrDefault().Value; + var name = inputResponses["name"].Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Content?.FirstOrDefault().Value; throw new InputRequiredException( inputRequests: new Dictionary { @@ -475,11 +475,11 @@ private static string MrtrConcurrentThree(RequestContext responses.ContainsKey("sample") && responses.ContainsKey("roots")) { - var elicitAction = responses["elicit"].Deserialize(InputResponse.ElicitResultTypeInfo)?.Action; - var sampleText = responses["sample"].Deserialize(InputResponse.SamplingResultTypeInfo)? + var elicitAction = responses["elicit"].Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Action; + var sampleText = responses["sample"].Deserialize(InputResponse.CreateMessageResultJsonTypeInfo)? .Content.OfType().FirstOrDefault()?.Text; var rootUris = string.Join(",", - responses["roots"].Deserialize(InputResponse.RootsResultTypeInfo)?.Roots.Select(r => r.Uri) ?? []); + responses["roots"].Deserialize(InputResponse.ListRootsResultJsonTypeInfo)?.Roots.Select(r => r.Uri) ?? []); return $"all-ok:elicit={elicitAction},sample={sampleText},roots={rootUris}"; } @@ -596,7 +596,7 @@ public async Task Mrtr_Backcompat_Roots_ResolvedViaLegacyJsonRpc() if (context.Params!.InputResponses is { } responses && responses.TryGetValue("roots", out var response)) { - var roots = response.Deserialize(InputResponse.RootsResultTypeInfo)?.Roots; + var roots = response.Deserialize(InputResponse.ListRootsResultJsonTypeInfo)?.Roots; return $"roots-ok:{roots?.FirstOrDefault()?.Name}"; } @@ -633,8 +633,8 @@ public async Task Mrtr_Backcompat_MultipleInputRequests_ResolvedViaLegacyJsonRpc responses.TryGetValue("confirm", out var elicitResponse) && responses.TryGetValue("summarize", out var sampleResponse)) { - var action = elicitResponse.Deserialize(InputResponse.ElicitResultTypeInfo)?.Action; - var text = sampleResponse.Deserialize(InputResponse.SamplingResultTypeInfo)?.Content.OfType().FirstOrDefault()?.Text; + var action = elicitResponse.Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Action; + var text = sampleResponse.Deserialize(InputResponse.CreateMessageResultJsonTypeInfo)?.Content.OfType().FirstOrDefault()?.Text; return $"both:{action}:{text}"; } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs index 32cf9508f..1dede3079 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs @@ -93,37 +93,6 @@ public async Task ToolThatThrows_ReturnsJsonRpcError_NotIncompleteResult() Assert.Contains("Tool validation failed", error.Error.Message); } - [Fact] - public async Task RetryWithInvalidRequestState_ReturnsJsonRpcError() - { - await StartAsync(); - await InitializeWithMrtrAsync(); - - // Send a retry with a requestState that doesn't match any active continuation - var retryParams = new JsonObject - { - ["name"] = "elicit-tool", - ["arguments"] = new JsonObject { ["message"] = "test" }, - ["inputResponses"] = new JsonObject { ["key1"] = new JsonObject { ["action"] = "confirm" } }, - ["requestState"] = "nonexistent-state-id" - }; - - var response = await PostJsonRpcAsync(Request("tools/call", retryParams.ToJsonString())); - - // Read as a generic JsonRpcMessage to check if it's an error - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var sseData = Assert.Single(await ReadSseAsync(response.Content).ToListAsync(TestContext.Current.CancellationToken)); - var message = JsonSerializer.Deserialize(sseData, McpJsonUtilities.DefaultOptions); - - // Invalid requestState should result in a fresh tool invocation - // (the tool will return InputRequiredResult since it calls ElicitAsync) - // or an error, depending on the implementation. - // In our implementation, unrecognized requestState triggers a new invocation. - Assert.True( - message is JsonRpcResponse or JsonRpcError, - $"Expected JsonRpcResponse or JsonRpcError, got {message?.GetType().Name}"); - } - // --- Helpers --- private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json"); diff --git a/tests/ModelContextProtocol.ConformanceServer/Prompts/IncompleteResultPrompts.cs b/tests/ModelContextProtocol.ConformanceServer/Prompts/IncompleteResultPrompts.cs index abbd60bb5..4dfe6dfb0 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Prompts/IncompleteResultPrompts.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Prompts/IncompleteResultPrompts.cs @@ -23,7 +23,7 @@ public static GetPromptResult IncompleteResultPrompt(RequestContext().FirstOrDefault()?.Text ?? "(no text)"; + var text = response.Deserialize(InputResponse.CreateMessageResultJsonTypeInfo)?.Content?.OfType().FirstOrDefault()?.Text ?? "(no text)"; return TextResult($"Sampling said: {text}"); } @@ -88,7 +88,7 @@ public static CallToolResult ToolWithListRoots(RequestContext(json, McpJsonUtilities.DefaultOptions); Assert.NotNull(deserialized); - var sampling = deserialized.Deserialize(InputResponse.SamplingResultTypeInfo); + var sampling = deserialized.Deserialize(InputResponse.CreateMessageResultJsonTypeInfo); Assert.NotNull(sampling); Assert.Equal("test-model", sampling.Model); } @@ -198,7 +198,7 @@ public static void InputResponse_FromElicitResult_RoundTrip() var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); Assert.NotNull(deserialized); - var elicit = deserialized.Deserialize(InputResponse.ElicitResultTypeInfo); + var elicit = deserialized.Deserialize(InputResponse.ElicitResultJsonTypeInfo); Assert.NotNull(elicit); Assert.Equal("confirm", elicit.Action); } @@ -217,7 +217,7 @@ public static void InputResponse_FromRootsResult_RoundTrip() var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); Assert.NotNull(deserialized); - var roots = deserialized.Deserialize(InputResponse.RootsResultTypeInfo); + var roots = deserialized.Deserialize(InputResponse.ListRootsResultJsonTypeInfo); Assert.NotNull(roots); Assert.Single(roots.Roots); Assert.Equal("file:///test", roots.Roots[0].Uri); diff --git a/tests/ModelContextProtocol.Tests/Server/DraftProtocolBackcompatTests.cs b/tests/ModelContextProtocol.Tests/Server/DraftProtocolBackcompatTests.cs new file mode 100644 index 000000000..81ed7b3e0 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/DraftProtocolBackcompatTests.cs @@ -0,0 +1,151 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Verifies that the high-level server-to-client request methods (, +/// , +/// ) keep working when the negotiated protocol revision is +/// DRAFT-2026-v1 on a stateful session — for example, stdio. +/// +/// +/// Under DRAFT-2026-v1 the spec removes the corresponding server-to-client request methods, but +/// the SDK only fails fast in stateless mode (where the existing ThrowIf*Unsupported guards already +/// throw "X is not supported in stateless mode" because is +/// ). Stdio is implicitly stateful — one per process — so the +/// legacy elicitation/create / sampling/createMessage / roots/list flow still works. +/// A future PR is expected to force DRAFT-2026-v1 Streamable HTTP servers to stateless mode, at which +/// point those configurations will start throwing through the existing stateless guard. +/// +public sealed class DraftProtocolBackcompatTests : ClientServerTestBase +{ + public DraftProtocolBackcompatTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + services.Configure(options => + { + options.ProtocolVersion = "DRAFT-2026-v1"; + }); + + mcpServerBuilder.WithTools([ + McpServerTool.Create(ElicitToolAsync, new() { Name = "elicit-tool" }), + McpServerTool.Create(SampleToolAsync, new() { Name = "sample-tool" }), + McpServerTool.Create(RootsToolAsync, new() { Name = "roots-tool" }), + ]); + } + + [Fact] + public async Task ElicitAsync_OnStatefulDraftSession_ResolvesViaLegacyRequest() + { + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = "DRAFT-2026-v1", + Capabilities = new ClientCapabilities + { + Elicitation = new ElicitationCapability(), + }, + Handlers = new McpClientHandlers + { + ElicitationHandler = (_, _) => new ValueTask(new ElicitResult { Action = "accept" }), + }, + }); + + var result = await client.CallToolAsync("elicit-tool", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("elicit-ok:accept", Assert.IsType(result.Content[0]).Text); + } + + [Fact] + public async Task SampleAsync_OnStatefulDraftSession_ResolvesViaLegacyRequest() + { + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = "DRAFT-2026-v1", + Capabilities = new ClientCapabilities + { + Sampling = new SamplingCapability(), + }, + Handlers = new McpClientHandlers + { + SamplingHandler = (_, _, _) => new ValueTask(new CreateMessageResult + { + Model = "test-model", + Role = Role.Assistant, + Content = [new TextContentBlock { Text = "hello back" }], + }), + }, + }); + + var result = await client.CallToolAsync("sample-tool", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("sample-ok:hello back", Assert.IsType(result.Content[0]).Text); + } + + [Fact] + public async Task RequestRootsAsync_OnStatefulDraftSession_ResolvesViaLegacyRequest() + { + StartServer(); + await using var client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = "DRAFT-2026-v1", + Capabilities = new ClientCapabilities + { + Roots = new RootsCapability(), + }, + Handlers = new McpClientHandlers + { + RootsHandler = (_, _) => new ValueTask(new ListRootsResult + { + Roots = [new Root { Uri = "file:///home", Name = "home" }], + }), + }, + }); + + var result = await client.CallToolAsync("roots-tool", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("roots-ok:file:///home", Assert.IsType(result.Content[0]).Text); + } + + private static async Task ElicitToolAsync(McpServer server, CancellationToken cancellationToken) + { + var elicit = await server.ElicitAsync(new ElicitRequestParams + { + Message = "Need input", + RequestedSchema = new(), + }, cancellationToken); + return $"elicit-ok:{elicit.Action}"; + } + + private static async Task SampleToolAsync(McpServer server, CancellationToken cancellationToken) + { + var sample = await server.SampleAsync(new CreateMessageRequestParams + { + Messages = + [ + new SamplingMessage + { + Role = Role.User, + Content = [new TextContentBlock { Text = "ping" }], + }, + ], + MaxTokens = 16, + }, cancellationToken); + var text = sample.Content.OfType().FirstOrDefault()?.Text; + return $"sample-ok:{text}"; + } + + private static async Task RootsToolAsync(McpServer server, CancellationToken cancellationToken) + { + var roots = await server.RequestRootsAsync(new ListRootsRequestParams(), cancellationToken); + return $"roots-ok:{roots.Roots.FirstOrDefault()?.Uri}"; + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/DraftProtocolGuardTests.cs b/tests/ModelContextProtocol.Tests/Server/DraftProtocolGuardTests.cs deleted file mode 100644 index e74ad20ba..000000000 --- a/tests/ModelContextProtocol.Tests/Server/DraftProtocolGuardTests.cs +++ /dev/null @@ -1,109 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using ModelContextProtocol.Client; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; - -namespace ModelContextProtocol.Tests.Server; - -public sealed class DraftProtocolGuardTests : ClientServerTestBase -{ - public DraftProtocolGuardTests(ITestOutputHelper testOutputHelper) - : base(testOutputHelper, startServer: false) - { - } - - protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) - { - services.Configure(options => - { - options.ProtocolVersion = "DRAFT-2026-v1"; - }); - - mcpServerBuilder.WithTools([ - McpServerTool.Create(AssertElicitAsyncGuardAsync, new() { Name = "assert-elicit-guard" }), - McpServerTool.Create(AssertSampleAsyncGuardAsync, new() { Name = "assert-sample-guard" }), - McpServerTool.Create(AssertRequestRootsAsyncGuardAsync, new() { Name = "assert-roots-guard" }), - ]); - } - - [Fact] - public async Task ElicitAsync_ThrowsUnderDraftProtocol() - { - StartServer(); - await using var client = await CreateDraftClientAsync(); - - var result = await client.CallToolAsync("assert-elicit-guard", cancellationToken: TestContext.Current.CancellationToken); - - Assert.Equal("ok", Assert.IsType(result.Content[0]).Text); - } - - [Fact] - public async Task SampleAsync_ThrowsUnderDraftProtocol() - { - StartServer(); - await using var client = await CreateDraftClientAsync(); - - var result = await client.CallToolAsync("assert-sample-guard", cancellationToken: TestContext.Current.CancellationToken); - - Assert.Equal("ok", Assert.IsType(result.Content[0]).Text); - } - - [Fact] - public async Task RequestRootsAsync_ThrowsUnderDraftProtocol() - { - StartServer(); - await using var client = await CreateDraftClientAsync(); - - var result = await client.CallToolAsync("assert-roots-guard", cancellationToken: TestContext.Current.CancellationToken); - - Assert.Equal("ok", Assert.IsType(result.Content[0]).Text); - } - - private Task CreateDraftClientAsync() => - CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" }); - - private static async Task AssertElicitAsyncGuardAsync(McpServer server, CancellationToken cancellationToken) - { - var exception = await Assert.ThrowsAsync(() => - server.ElicitAsync(new ElicitRequestParams - { - Message = "Need input", - RequestedSchema = new(), - }, cancellationToken).AsTask()); - - Assert.Contains("DRAFT-2026-v1", exception.Message); - Assert.Contains("InputRequest.ForElicitation", exception.Message); - return "ok"; - } - - private static async Task AssertSampleAsyncGuardAsync(McpServer server, CancellationToken cancellationToken) - { - var exception = await Assert.ThrowsAsync(() => - server.SampleAsync(new CreateMessageRequestParams - { - Messages = - [ - new SamplingMessage - { - Role = Role.User, - Content = [new TextContentBlock { Text = "Hello" }], - }, - ], - MaxTokens = 1, - }, cancellationToken).AsTask()); - - Assert.Contains("DRAFT-2026-v1", exception.Message); - Assert.Contains("InputRequest.ForSampling", exception.Message); - return "ok"; - } - - private static async Task AssertRequestRootsAsyncGuardAsync(McpServer server, CancellationToken cancellationToken) - { - var exception = await Assert.ThrowsAsync(() => - server.RequestRootsAsync(new ListRootsRequestParams(), cancellationToken).AsTask()); - - Assert.Contains("DRAFT-2026-v1", exception.Message); - Assert.Contains("InputRequest.ForRootsList", exception.Message); - return "ok"; - } -} From a8c60d09403ab9c256e79a90061e6ee5294cd144 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 28 May 2026 08:37:07 -0700 Subject: [PATCH 09/14] Pre-undraft cleanup: revert unrelated noise, drop low-level/high-level 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> --- .../Protocol/InputRequiredException.cs | 4 +- .../Server/AIFunctionMcpServerTool.cs | 2 +- .../Server/DelegatingMcpServerTool.cs | 2 +- .../Server/DestinationBoundMcpServer.cs | 2 +- .../Server/McpServerImpl.cs | 25 ++---- .../Server/McpServerTool.cs | 1 - .../Server/McpServerToolAttribute.cs | 3 +- tests/Common/Utils/ServerMessageTracker.cs | 10 +++ .../MapMcpTests.Mrtr.cs | 82 +++++++------------ .../MapMcpTests.cs | 1 - .../Tools/IncompleteResultTools.cs | 2 +- .../Server/DraftProtocolBackcompatTests.cs | 2 +- .../Server/McpServerTests.cs | 2 +- ....cs => MrtrInputRequiredExceptionTests.cs} | 8 +- 14 files changed, 59 insertions(+), 87 deletions(-) rename tests/ModelContextProtocol.Tests/Server/{MrtrLowLevelApiTests.cs => MrtrInputRequiredExceptionTests.cs} (86%) diff --git a/src/ModelContextProtocol.Core/Protocol/InputRequiredException.cs b/src/ModelContextProtocol.Core/Protocol/InputRequiredException.cs index eec7ccf29..4f39b17a5 100644 --- a/src/ModelContextProtocol.Core/Protocol/InputRequiredException.cs +++ b/src/ModelContextProtocol.Core/Protocol/InputRequiredException.cs @@ -8,7 +8,7 @@ namespace ModelContextProtocol.Protocol; /// /// /// -/// This exception is part of the low-level Multi Round-Trip Requests (MRTR) API. Tool handlers +/// This exception is part of the Multi Round-Trip Requests (MRTR) API. Tool handlers /// throw this exception to directly control the input-required result payload, including /// and . /// @@ -30,7 +30,7 @@ namespace ModelContextProtocol.Protocol; /// /// /// -/// [McpServerTool, Description("A stateless tool using low-level MRTR")] +/// [McpServerTool, Description("A stateless tool using MRTR")] /// public static string MyTool(McpServer server, RequestContext<CallToolRequestParams> context) /// { /// if (context.Params.RequestState is { } state) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 8fbf99581..961344c2c 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; using System.ComponentModel; diff --git a/src/ModelContextProtocol.Core/Server/DelegatingMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/DelegatingMcpServerTool.cs index 3835d1b98..775930090 100644 --- a/src/ModelContextProtocol.Core/Server/DelegatingMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/DelegatingMcpServerTool.cs @@ -1,4 +1,4 @@ -using ModelContextProtocol.Protocol; +using ModelContextProtocol.Protocol; namespace ModelContextProtocol.Server; diff --git a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs index 51ca91498..6599fb0b4 100644 --- a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs @@ -14,7 +14,7 @@ internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport public override IServiceProvider? Services => server.Services; public override LoggingLevel? LoggingLevel => server.LoggingLevel; - public override bool IsMrtrSupported => server.IsLowLevelMrtrAvailable(); + public override bool IsMrtrSupported => server.IsMrtrSupported; public override ValueTask DisposeAsync() => server.DisposeAsync(); diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index ffc440bd1..eaf0d603a 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -781,7 +781,7 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) // (client request cancellation, session shutdown, MRTR teardown), not a // tool error. // Skip logging for InputRequiredException — it's normal MRTR control flow, - // not an error (the low-level API uses it to signal an InputRequiredResult). + // not an error (tools throw it to signal an InputRequiredResult). if (!(e is OperationCanceledException && cancellationToken.IsCancellationRequested) && e is not InputRequiredException) { ToolCallError(request.Params?.Name ?? string.Empty, e); @@ -1134,26 +1134,17 @@ internal bool ClientSupportsMrtr() => _negotiatedProtocolVersion == McpSessionHandler.DraftProtocolVersion; /// - /// Returns when the session is stateful (i.e., the same server instance - /// will handle subsequent requests). The implicit MRTR path — where a handler can call - /// ElicitAsync/SampleAsync and the SDK suspends/resumes the handler across an - /// round trip — requires the continuation map to outlive the - /// initial response, so it is only available on stateful sessions. Stateless transports always - /// go through the exception-based path. + /// Returns when the session is stateful — the same server instance handles + /// subsequent requests on the same session. The legacy backcompat resolver in + /// needs a stateful session so it can send + /// elicitation/create / sampling/createMessage / roots/list to the client and + /// retry the handler with the responses. /// internal bool IsStatefulSession() => _sessionTransport is not StreamableHttpServerTransport { Stateless: true }; - /// - /// Checks whether the low-level MRTR API () is available - /// for the current request. Returns in all cases except stateless mode - /// with a client that hasn't negotiated MRTR — that's the one configuration where nobody can - /// drive the retry loop (the server can't send JSON-RPC requests to the client, and the client - /// doesn't know about InputRequiredResult). - /// - internal bool IsLowLevelMrtrAvailable() => - ClientSupportsMrtr() || - _sessionTransport is not StreamableHttpServerTransport { Stateless: true }; + /// + public override bool IsMrtrSupported => ClientSupportsMrtr() || IsStatefulSession(); /// /// Invokes a handler and catches to convert it to an diff --git a/src/ModelContextProtocol.Core/Server/McpServerTool.cs b/src/ModelContextProtocol.Core/Server/McpServerTool.cs index 8144311e0..e2a9a34e0 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerTool.cs @@ -2,7 +2,6 @@ using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; -using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Text.Json; diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs index 4caf07197..d67bac18c 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs @@ -325,5 +325,4 @@ public ToolTaskSupport TaskSupport get => _taskSupport ?? ToolTaskSupport.Forbidden; set => _taskSupport = value; } - -} \ No newline at end of file +} diff --git a/tests/Common/Utils/ServerMessageTracker.cs b/tests/Common/Utils/ServerMessageTracker.cs index f58a1a9be..66a80c681 100644 --- a/tests/Common/Utils/ServerMessageTracker.cs +++ b/tests/Common/Utils/ServerMessageTracker.cs @@ -72,6 +72,16 @@ public void AssertMrtrUsed() Assert.Empty(_legacyRequestMethods); } + /// + /// Asserts that MRTR was used at least once (at least one InputRequiredResult response was sent), + /// independent of whether the session also issued any legacy server-to-client requests. + /// + public void AssertMrtrUsedAtLeastOnce() + { + Assert.True(_incompleteResultCount > 0, + "Expected at least one InputRequiredResult response (MRTR mode), but none were detected."); + } + /// /// Asserts that legacy mode was used: at least one legacy JSON-RPC request was sent /// and no MRTR retries or InputRequiredResult responses were detected. diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs index 1d81723fb..a6350e5cc 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs @@ -153,6 +153,7 @@ private static async Task MrtrMixed(McpServer server, RequestContext MrtrParallelAwait(McpServer server, CancellationToken ct) { - // Start the first await — succeeds with MRTR (creates exchange) var elicitTask = server.ElicitAsync(new ElicitRequestParams { Message = "Parallel elicit", RequestedSchema = new() }, ct); - // Start the second await. This path is only exercised for legacy clients now - // that draft clients must use InputRequiredException instead of await-style requests. - try + var sampleTask = server.SampleAsync(new CreateMessageRequestParams { - var sampleTask = server.SampleAsync(new CreateMessageRequestParams - { - Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Parallel sample" }] }], - MaxTokens = 100 - }, ct); + Messages = [new SamplingMessage { Role = Role.User, Content = [new TextContentBlock { Text = "Parallel sample" }] }], + MaxTokens = 100 + }, ct); - // If we get here, both calls succeeded (non-MRTR path) - var sampleResult = await sampleTask; - var elicitResult = await elicitTask; - return $"parallel-ok:{elicitResult.Action}:{sampleResult.Content.OfType().First().Text}"; - } - catch (InvalidOperationException ex) - { - return ex.Message; - } + var sampleResult = await sampleTask; + var elicitResult = await elicitTask; + return $"parallel-ok:{elicitResult.Action}:{sampleResult.Content.OfType().First().Text}"; } - [Theory] - [InlineData(false)] - public async Task Mrtr_ParallelAwaits(bool experimentalClient) + [Fact] + public async Task Mrtr_ParallelAwaits() { - // Parallel awaits work with regular JSON-RPC for legacy clients. - Assert.SkipWhen(Stateless, "Await-style API requires handler suspension (stateful only)."); + // Server-side parallel ElicitAsync + SampleAsync awaits use the legacy server-to-client + // request path on stateful sessions, which works the same under either negotiated revision + // (the spec only removes those request methods from Streamable HTTP under draft, which is + // stateless-only territory). Stateless servers can't issue server-to-client requests at all. + Assert.SkipWhen(Stateless, "Server-side awaits require stateful server-to-client requests."); - var messageTracker = ConfigureServer(MrtrParallelAwait); + ConfigureServer(MrtrParallelAwait); await using var app = Builder.Build(); app.MapMcp(); await app.StartAsync(TestContext.Current.CancellationToken); - Action configureClient = experimentalClient - ? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; } - : ConfigureMrtrHandlers; - await using var client = await ConnectAsync(configureClient: configureClient); - - if (experimentalClient) - { - // Draft clients must use InputRequiredException instead of await-style requests. - Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion); - - var result = await client.CallToolAsync("mrtr-parallel-await", - cancellationToken: TestContext.Current.CancellationToken); + await using var client = await ConnectAsync(configureClient: ConfigureMrtrHandlers); - var text = Assert.IsType(Assert.Single(result.Content)).Text; - Assert.Contains("Concurrent server-to-client requests are not supported", text); - Assert.True(result.IsError is not true); - } - else - { - // Non-MRTR: awaits go through regular JSON-RPC — concurrent calls work. - Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); - - var result = await client.CallToolAsync("mrtr-parallel-await", - cancellationToken: TestContext.Current.CancellationToken); + var result = await client.CallToolAsync("mrtr-parallel-await", + cancellationToken: TestContext.Current.CancellationToken); - var text = Assert.IsType(Assert.Single(result.Content)).Text; - Assert.StartsWith("parallel-ok:", text); - Assert.True(result.IsError is not true); - } + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.StartsWith("parallel-ok:", text); + Assert.True(result.IsError is not true); } [McpServerTool(Name = "mrtr-elicit")] @@ -321,7 +295,7 @@ private static string MrtrElicit(RequestContext context) } [Fact] - public async Task Mrtr_LowLevel_Roots_CompletesViaMrtr() + public async Task Mrtr_Roots_CompletesViaMrtr() { var messageTracker = ConfigureServer( [McpServerTool(Name = "mrtr-roots")] (RequestContext context) => @@ -558,7 +532,7 @@ public async Task Mrtr_ConcurrentThreeInputs_ResolvedSimultaneously() } [Fact] - public async Task Mrtr_Experimental_LoadShedding_RequestStateOnly_CompletesViaMrtr() + public async Task Mrtr_LoadShedding_RequestStateOnly_CompletesViaMrtr() { var messageTracker = ConfigureServer( [McpServerTool(Name = "mrtr-loadshed")] (RequestContext context) => diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs index bde13f5c3..881b03208 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs @@ -288,7 +288,6 @@ public async Task LongRunningToolCall_DoesNotTimeout_WhenNoEventStreamStore() } - [Fact] public async Task IncomingFilter_SeesClientRequests() { diff --git a/tests/ModelContextProtocol.ConformanceServer/Tools/IncompleteResultTools.cs b/tests/ModelContextProtocol.ConformanceServer/Tools/IncompleteResultTools.cs index f572b263f..e4f373245 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Tools/IncompleteResultTools.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Tools/IncompleteResultTools.cs @@ -11,7 +11,7 @@ namespace ConformanceServer.Tools; /// /// Tools implementing the SEP-2322 (MRTR / IncompleteResult) conformance scenarios from -/// incomplete-result.ts in the conformance test suite. All tools use the low-level +/// incomplete-result.ts in the conformance test suite. All tools use the /// API so they work both in stateful sessions with /// MRTR-aware clients and in legacy-resolve mode (the SDK will translate exceptions to the /// proper wire shape based on negotiated protocol version). diff --git a/tests/ModelContextProtocol.Tests/Server/DraftProtocolBackcompatTests.cs b/tests/ModelContextProtocol.Tests/Server/DraftProtocolBackcompatTests.cs index 81ed7b3e0..2c82679d7 100644 --- a/tests/ModelContextProtocol.Tests/Server/DraftProtocolBackcompatTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/DraftProtocolBackcompatTests.cs @@ -6,7 +6,7 @@ namespace ModelContextProtocol.Tests.Server; /// -/// Verifies that the high-level server-to-client request methods (, +/// Verifies that the server-to-client request methods (, /// , /// ) keep working when the negotiated protocol revision is /// DRAFT-2026-v1 on a stateful session — for example, stdio. diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index 64de19b26..b8bd57b02 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -1370,7 +1370,7 @@ public override Task SendRequestAsync(JsonRpcRequest request, C public override ValueTask DisposeAsync() => default; public override string? SessionId => throw new NotImplementedException(); - public override string? NegotiatedProtocolVersion => null; + public override string? NegotiatedProtocolVersion => throw new NotImplementedException(); public override Implementation? ClientInfo => throw new NotImplementedException(); public override IServiceProvider? Services => throw new NotImplementedException(); public override LoggingLevel? LoggingLevel => throw new NotImplementedException(); diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrLowLevelApiTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs similarity index 86% rename from tests/ModelContextProtocol.Tests/Server/MrtrLowLevelApiTests.cs rename to tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs index f95439e32..ac7e38f33 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrLowLevelApiTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs @@ -7,14 +7,14 @@ namespace ModelContextProtocol.Tests.Server; /// -/// Tests for the low-level MRTR server API — IsMrtrSupported, InputRequiredException, +/// Tests for the MRTR server API — IsMrtrSupported, InputRequiredException, /// and client auto-retry of incomplete results. /// -public class MrtrLowLevelApiTests : ClientServerTestBase +public class MrtrInputRequiredExceptionTests : ClientServerTestBase { private readonly ServerMessageTracker _messageTracker = new(); - public MrtrLowLevelApiTests(ITestOutputHelper testOutputHelper) + public MrtrInputRequiredExceptionTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper, startServer: false) { } @@ -42,7 +42,7 @@ static string (McpServer server) => } [Fact] - public async Task LowLevel_InputRequiredException_WithoutInputRequests_ExhaustsRetries() + public async Task InputRequiredException_WithoutInputRequests_ExhaustsRetries() { StartServer(); var clientOptions = new McpClientOptions(); From 6148b439f10586d3084864b416724eba1ff2e510 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 28 May 2026 08:59:35 -0700 Subject: [PATCH 10/14] Remove stale 'Deferred Task Creation with MRTR' section from tasks.md to fix docfx warnings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/tasks/tasks.md | 52 ------------------------------------ 1 file changed, 52 deletions(-) diff --git a/docs/concepts/tasks/tasks.md b/docs/concepts/tasks/tasks.md index e5294f513..91f86db1a 100644 --- a/docs/concepts/tasks/tasks.md +++ b/docs/concepts/tasks/tasks.md @@ -137,58 +137,6 @@ Task support levels: - `Optional` (default for async methods): Tool can be called with or without task augmentation - `Required`: Tool must be called with task augmentation -### Deferred Task Creation with MRTR - - -> [!WARNING] -> Deferred task creation depends on both the [Tasks](xref:tasks) and [MRTR](xref:mrtr) experimental features. - -By default, when a client sends task metadata with a `tools/call` request, the SDK creates a task immediately and runs the tool in the background. **Deferred task creation** delays the task creation, letting the tool perform ephemeral [MRTR](xref:mrtr) exchanges first — for example, to confirm an action with the user or gather required parameters — before committing to a background task. - -To opt in, set `DeferTaskCreation = true` on the tool: - -```csharp -McpServerTool.Create( - async (string vmName, McpServer server, CancellationToken ct) => - { - // Ephemeral MRTR — uses incomplete result / retry cycle. - var confirmation = await server.ElicitAsync(new ElicitRequestParams - { - Message = $"Provision VM '{vmName}'? This will incur costs.", - RequestedSchema = new() - }, ct); - - if (confirmation.Action != "confirm") - { - return "Cancelled by user."; - } - - // Transition to a background task. - await server.CreateTaskAsync(ct); - - // Background work — runs as a task, client polls for status. - await Task.Delay(TimeSpan.FromMinutes(5), ct); - return $"VM '{vmName}' provisioned successfully."; - }, - new McpServerToolCreateOptions - { - Name = "provision-vm", - Description = "Provisions a VM with user confirmation", - DeferTaskCreation = true, - Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional }, - }) -``` - -After returns: - -- The MRTR phase ends. The client receives a `CreateTaskResult` with the `taskId`. -- Any subsequent `ElicitAsync` or `SampleAsync` calls in the handler use the task's `input_required` / `tasks/input_response` workflow instead of MRTR. -- The handler's cancellation token is re-linked to the task's lifecycle (TTL expiration, explicit `tasks/cancel`). - -If the tool returns without calling `CreateTaskAsync`, a normal (non-task) result is sent to the client — no task is created. - -For more details on the MRTR mechanism and the transition flow, see [Transitioning from MRTR to Tasks](xref:mrtr#transitioning-from-mrtr-to-tasks). - ### Explicit Task Creation with `IMcpTaskStore` For more control over task lifecycle, tools can directly interact with and return an `McpTask`. This approach allows you to: From c2467d37d8a4b735e01d8f2cd61dcc4e44d62baa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 28 May 2026 17:30:05 +0000 Subject: [PATCH 11/14] Increase test hang timeout to stabilize debug CI job Co-authored-by: halter73 <54385+halter73@users.noreply.github.com> --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 993c3dd76..3db693f13 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ test: build --filter '(Execution!=Manual)' \ --blame \ --blame-crash \ - --blame-hang-timeout 7m \ + --blame-hang-timeout 15m \ --diag "$(ARTIFACT_PATH)/diag.txt" \ --logger "trx" \ --collect "XPlat Code Coverage" \ From e1e81e52770e509a4a8c16bdffac1be59702b904 Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 28 May 2026 10:34:27 -0700 Subject: [PATCH 12/14] Route MRTR backcompat resolver requests through destination-bound transport 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> --- .../Server/McpServerImpl.cs | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index eaf0d603a..5903cbf09 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -1208,7 +1208,13 @@ internal bool IsStatefulSession() => } // Resolve each input request by sending the corresponding JSON-RPC call to the client. - var inputResponses = await ResolveInputRequestsAsync(inputRequests, cancellationToken).ConfigureAwait(false); + // Route the outgoing requests via the same DestinationBoundMcpServer used for normal tool + // handlers, so they go through the POST's response stream (RelatedTransport) rather than + // the session-level transport. Without this, the messages can race with the client's GET + // stream startup and be silently dropped by StreamableHttpServerTransport.SendMessageAsync + // when no GET request has arrived yet. + var destinationServer = CreateDestinationBoundServer(request); + var inputResponses = await ResolveInputRequestsAsync(destinationServer, inputRequests, cancellationToken).ConfigureAwait(false); // Reconstruct request params with inputResponses and requestState for the retry. var paramsObj = request.Params?.DeepClone() as JsonObject ?? new JsonObject(); @@ -1233,12 +1239,15 @@ internal bool IsStatefulSession() => /// /// Resolves a batch of MRTR input requests concurrently by dispatching each as a standard - /// JSON-RPC request to the client. On the first failure all remaining handlers are cancelled - /// so user-facing flows (sampling/elicitation prompts) don't keep running once the caller has - /// given up, and exceptions from late-completing tasks are observed before the original - /// exception is rethrown. + /// JSON-RPC request to the client. The requests are routed via + /// so they go out through the POST's response stream (matching the behavior of tool-initiated + /// server-to-client requests like server.SampleAsync) and avoid racing with the client's + /// GET stream startup. On the first failure all remaining handlers are cancelled so user-facing + /// flows (sampling/elicitation prompts) don't keep running once the caller has given up, and + /// exceptions from late-completing tasks are observed before the original exception is rethrown. /// - private async Task> ResolveInputRequestsAsync( + private static async Task> ResolveInputRequestsAsync( + McpServer destinationServer, IDictionary inputRequests, CancellationToken cancellationToken) { @@ -1248,7 +1257,7 @@ private async Task> ResolveInputRequestsAsync int i = 0; foreach (var kvp in inputRequests) { - keyed[i++] = (kvp.Key, ResolveInputRequestAsync(kvp.Value, linkedCts.Token)); + keyed[i++] = (kvp.Key, ResolveInputRequestAsync(destinationServer, kvp.Value, linkedCts.Token)); } try @@ -1279,28 +1288,29 @@ private async Task> ResolveInputRequestsAsync /// /// Resolves a single MRTR by dispatching it as a standard JSON-RPC - /// request to the client. This is the server-side mirror of the client's input resolution logic, - /// used for backward compatibility when the client doesn't support MRTR. + /// request to the client via . This is the server-side mirror + /// of the client's input resolution logic, used for backward compatibility when the client doesn't + /// support MRTR. /// - private async Task ResolveInputRequestAsync(InputRequest inputRequest, CancellationToken cancellationToken) + private static async Task ResolveInputRequestAsync(McpServer destinationServer, InputRequest inputRequest, CancellationToken cancellationToken) { switch (inputRequest.Method) { case RequestMethods.ElicitationCreate: var elicitParams = inputRequest.ElicitationParams ?? throw new McpException("Failed to deserialize elicitation parameters from MRTR input request."); - var elicitResult = await ElicitAsync(elicitParams, cancellationToken).ConfigureAwait(false); + var elicitResult = await destinationServer.ElicitAsync(elicitParams, cancellationToken).ConfigureAwait(false); return InputResponse.FromElicitResult(elicitResult); case RequestMethods.SamplingCreateMessage: var samplingParams = inputRequest.SamplingParams ?? throw new McpException("Failed to deserialize sampling parameters from MRTR input request."); - var samplingResult = await SampleAsync(samplingParams, cancellationToken).ConfigureAwait(false); + var samplingResult = await destinationServer.SampleAsync(samplingParams, cancellationToken).ConfigureAwait(false); return InputResponse.FromSamplingResult(samplingResult); case RequestMethods.RootsList: var rootsParams = inputRequest.RootsParams ?? new ListRootsRequestParams(); - var rootsResult = await RequestRootsAsync(rootsParams, cancellationToken).ConfigureAwait(false); + var rootsResult = await destinationServer.RequestRootsAsync(rootsParams, cancellationToken).ConfigureAwait(false); return InputResponse.FromRootsResult(rootsResult); default: From 17dc39ca29d751265f9fd554f82d3167bacac03c Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 28 May 2026 10:42:09 -0700 Subject: [PATCH 13/14] Revert blame-hang-timeout increase --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 3db693f13..993c3dd76 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ test: build --filter '(Execution!=Manual)' \ --blame \ --blame-crash \ - --blame-hang-timeout 15m \ + --blame-hang-timeout 7m \ --diag "$(ARTIFACT_PATH)/diag.txt" \ --logger "trx" \ --collect "XPlat Code Coverage" \ From 5bf84d255988d4a302f98c39fb1daa1465e0852b Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Thu, 28 May 2026 11:21:01 -0700 Subject: [PATCH 14/14] Add raw-HTTP regression test for MRTR backcompat resolver routing 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> --- .../MrtrProtocolTests.cs | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs index 1dede3079..76625f654 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs @@ -93,6 +93,157 @@ public async Task ToolThatThrows_ReturnsJsonRpcError_NotIncompleteResult() Assert.Contains("Tool validation failed", error.Error.Message); } + /// + /// Regression test for a CI hang where the server-side MRTR backcompat resolver routed its + /// outgoing roots/list request through the session-level transport, which silently + /// dropped the message when the client's GET stream had not been established yet. The + /// outgoing request must instead go through the POST's response stream (the request's + /// ) so it + /// reaches the client without depending on the GET stream at all. + /// + /// This test deliberately never opens a GET stream — it only POSTs the initialize, the + /// initialized notification, the tools/call, and the roots/list response. If the + /// server falls back to _transport.SendMessageAsync, the test times out instead of + /// reading the expected roots/list SSE event off the tools/call POST response. + /// + [Fact] + public async Task BackcompatResolver_SendsServerRequestOverPostStream_WithoutGetStream() + { + // Configure a server that does NOT pin DRAFT-2026-v1 so it can negotiate the current + // protocol with a legacy client. The backcompat resolver path only runs when the + // negotiated version is not DRAFT-2026-v1. + Builder.Services.AddMcpServer(options => + { + options.ServerInfo = new Implementation + { + Name = nameof(MrtrProtocolTests), + Version = "1", + }; + }).WithTools([ + McpServerTool.Create( + static string (RequestContext context) => + { + if (context.Params!.InputResponses is { } responses && + responses.TryGetValue("roots", out var response)) + { + var roots = response.Deserialize(InputResponse.ListRootsResultJsonTypeInfo)?.Roots; + return $"roots-ok:{roots?.FirstOrDefault()?.Name}"; + } + + throw new InputRequiredException( + inputRequests: new Dictionary + { + ["roots"] = InputRequest.ForRootsList(new ListRootsRequestParams()) + }, + requestState: "roots-state"); + }, + new McpServerToolCreateOptions + { + Name = "backcompat-roots-tool", + Description = "Throws InputRequiredException so the server's backcompat resolver issues a roots/list", + }), + ]).WithHttpTransport(); + + _app = Builder.Build(); + _app.MapMcp(); + await _app.StartAsync(TestContext.Current.CancellationToken); + + HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); + HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); + + // Initialize with the current (non-draft) protocol so the server's backcompat resolver runs. + var initJson = """ + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{"roots":{}},"clientInfo":{"name":"BackcompatTestClient","version":"1.0.0"}}} + """; + + string sessionId; + using (var initResponse = await PostJsonRpcAsync(initJson)) + { + var initRpcResponse = await AssertSingleSseResponseAsync(initResponse); + Assert.NotNull(initRpcResponse.Result); + Assert.Equal("2025-11-25", initRpcResponse.Result["protocolVersion"]?.GetValue()); + + sessionId = Assert.Single(initResponse.Headers.GetValues("mcp-session-id")); + } + + HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); + HttpClient.DefaultRequestHeaders.Add("mcp-session-id", sessionId); + HttpClient.DefaultRequestHeaders.Remove("MCP-Protocol-Version"); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "2025-11-25"); + + // Send the initialized notification. + using (var initializedResponse = await PostJsonRpcAsync( + """{"jsonrpc":"2.0","method":"notifications/initialized"}""")) + { + Assert.True(initializedResponse.IsSuccessStatusCode); + } + + _lastRequestId = 1; + + // POST the tools/call and start reading the response SSE stream. We deliberately do NOT + // open a GET stream — the server-to-client roots/list must be delivered on this POST's + // response. Use HttpCompletionOption.ResponseHeadersRead so the POST returns as soon as + // the response headers arrive instead of waiting for the SSE stream to close. + var callRequest = new HttpRequestMessage(HttpMethod.Post, (string?)null) + { + Content = JsonContent(CallTool("backcompat-roots-tool")), + }; + callRequest.Content.Headers.Add("Mcp-Method", "tools/call"); + callRequest.Content.Headers.Add("Mcp-Name", "backcompat-roots-tool"); + + using var callResponse = await HttpClient.SendAsync( + callRequest, + HttpCompletionOption.ResponseHeadersRead, + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, callResponse.StatusCode); + Assert.Equal("text/event-stream", callResponse.Content.Headers.ContentType?.MediaType); + + var sseEvents = ReadSseAsync(callResponse.Content) + .GetAsyncEnumerator(TestContext.Current.CancellationToken); + + try + { + // First SSE event on this POST should be the server-initiated roots/list request. + Assert.True(await sseEvents.MoveNextAsync(), + "Server did not send a roots/list request on the tools/call POST response stream. " + + "If this hangs/times out, the MRTR backcompat resolver is routing the outgoing request " + + "through the session-level transport instead of the POST's RelatedTransport."); + + var rootsRequestNode = JsonNode.Parse(sseEvents.Current) as JsonObject; + Assert.NotNull(rootsRequestNode); + Assert.Equal("roots/list", rootsRequestNode["method"]?.GetValue()); + var rootsRequestId = rootsRequestNode["id"]; + Assert.NotNull(rootsRequestId); + + // POST the roots/list response on a separate connection. The server's pending + // RequestRootsAsync await will complete and the backcompat resolver will retry the tool. + var rootsIdLiteral = rootsRequestId.ToJsonString(); + var rootsResponseJson = + "{\"jsonrpc\":\"2.0\",\"id\":" + rootsIdLiteral + + ",\"result\":{\"roots\":[{\"uri\":\"file:///workspace\",\"name\":\"Workspace\"}]}}"; + using (var rootsResponseHttp = await PostJsonRpcAsync(rootsResponseJson)) + { + Assert.True(rootsResponseHttp.IsSuccessStatusCode); + } + + // Next SSE event on the original POST should be the final tools/call response. + Assert.True(await sseEvents.MoveNextAsync(), "Server did not return the final tools/call response."); + var finalResponse = JsonSerializer.Deserialize(sseEvents.Current, GetJsonTypeInfo()); + Assert.NotNull(finalResponse); + Assert.NotNull(finalResponse.Result); + + var content = finalResponse.Result["content"]?.AsArray(); + Assert.NotNull(content); + var firstContent = Assert.Single(content); + Assert.Equal("roots-ok:Workspace", firstContent?["text"]?.GetValue()); + } + finally + { + await sseEvents.DisposeAsync(); + } + } + // --- Helpers --- private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json");