Multi Round-Trip Requests (MRTR) (#1458)

This commit is contained in:
Stephen Halter 2026-06-03 16:19:31 -07:00 • committed by GitHub
commit f6dbe4372f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 5832 additions and 103 deletions

View file

@ -205,6 +205,44 @@ public static class NodeHelpers
}
}
/// <summary>
/// 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).
/// </summary>
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))

View file

@ -0,0 +1,95 @@
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Collections.Concurrent;
using System.Text.Json.Nodes;
using Xunit;
namespace ModelContextProtocol.Tests.Utils;
/// <summary>
/// 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.
/// </summary>
internal sealed class ServerMessageTracker
{
private static readonly HashSet<string> LegacyMrtrMethods =
[
RequestMethods.ElicitationCreate,
RequestMethods.SamplingCreateMessage,
RequestMethods.RootsList,
];
private readonly ConcurrentBag<string> _legacyRequestMethods = [];
private int _mrtrRetryCount;
private int _incompleteResultCount;
/// <summary>
/// Adds incoming and outgoing message filters to track MRTR protocol usage.
/// Call this in <c>services.Configure&lt;McpServerOptions&gt;</c> or <c>AddMcpServer</c> callbacks.
/// </summary>
public void AddFilters(McpMessageFilters messageFilters)
{
// 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))
{
_legacyRequestMethods.Add(request.Method);
}
else if (context.JsonRpcMessage is JsonRpcResponse response &&
response.Result is JsonObject resultObj &&
resultObj.TryGetPropertyValue("resultType", out var resultTypeNode) &&
resultTypeNode?.GetValue<string>() == "input_required")
{
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);
});
}
/// <summary>
/// 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.
/// </summary>
public void AssertMrtrUsed()
{
Assert.True(_incompleteResultCount > 0,
"Expected at least one InputRequiredResult response (MRTR mode), but none were detected.");
Assert.Empty(_legacyRequestMethods);
}
/// <summary>
/// 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.
/// </summary>
public void AssertMrtrUsedAtLeastOnce()
{
Assert.True(_incompleteResultCount > 0,
"Expected at least one InputRequiredResult response (MRTR mode), but none were detected.");
}
/// <summary>
/// Asserts that legacy mode was used: at least one legacy JSON-RPC request was sent
/// and no MRTR retries or InputRequiredResult responses were detected.
/// </summary>
public void AssertMrtrNotUsed()
{
Assert.NotEmpty(_legacyRequestMethods);
Assert.Equal(0, _mrtrRetryCount);
Assert.Equal(0, _incompleteResultCount);
}
}

View file

@ -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<CallToolRequestParams> 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);
}
}

View file

@ -348,9 +348,9 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
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);
@ -458,41 +458,6 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
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<CallToolRequestParams> 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()
{
@ -793,13 +758,13 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
await using var app = Builder.Build();
// This is the pattern documented in sessions.md — verify it actually works.
// This is the pattern documented in sessions.md - verify it actually works.
// Tag before next() so child spans inherit the value.
app.MapMcp().AddEndpointFilter(async (context, next) =>
{
var httpContext = context.HttpContext;
// Read from request headers — available on all non-initialize requests in stateful mode.
// Read from request headers - available on all non-initialize requests in stateful mode.
string? beforeSessionId = httpContext.Request.Headers["Mcp-Session-Id"];
// Tag before next() so child activities created during the handler inherit it.
@ -828,7 +793,7 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
// The filter must have observed at least one MCP request. Don't assert an exact
// minimum — the initialized notification or GET stream may not have completed yet.
// minimum - the initialized notification or GET stream may not have completed yet.
Assert.NotEmpty(capturedSessionIds);
if (Stateless)
@ -855,7 +820,7 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
});
// At least one POST should have the session ID in the request header too
// (the initialized notification or list_tools — but not the initial initialize request).
// (the initialized notification or list_tools - but not the initial initialize request).
Assert.Contains(postCaptures, c => c.BeforeNext == client.SessionId);
// Verify Activity.Current was available and the AddTag pattern works before next().

View file

@ -0,0 +1,779 @@
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 ConfigureServer(params Delegate[] tools)
{
var messageTracker = new ServerMessageTracker();
Builder.Services.AddMcpServer(options =>
{
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)
.WithTools(tools.Select(t => McpServerTool.Create(t)));
return messageTracker;
}
private Task<McpClient> ConnectExperimentalAsync() =>
ConnectAsync(configureClient: options =>
{
ConfigureMrtrHandlers(options);
options.ProtocolVersion = "DRAFT-2026-v1";
});
private Task<McpClient> ConnectDefaultAsync() =>
ConnectAsync(configureClient: ConfigureMrtrHandlers);
/// <summary>Configures elicitation, sampling, and roots handlers on client options.</summary>
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<ElicitResult>(new ElicitResult
{
Action = "accept",
Content = new Dictionary<string, JsonElement>
{
["answer"] = JsonDocument.Parse($"\"{answer}\"").RootElement.Clone()
}
});
};
options.Handlers.SamplingHandler = (request, progress, ct) =>
{
var prompt = request?.Messages?.LastOrDefault()?.Content
.OfType<TextContentBlock>().FirstOrDefault()?.Text ?? "";
return new ValueTask<CreateMessageResult>(new CreateMessageResult
{
Content = [new TextContentBlock { Text = $"LLM:{prompt}" }],
Model = "test-model"
});
};
options.Handlers.RootsHandler = (request, ct) =>
{
return new ValueTask<ListRootsResult>(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 DRAFT-2026-v1 enabled.
// =====================================================================
[McpServerTool(Name = "mrtr-mixed")]
private static async Task<string> MrtrMixed(McpServer server, RequestContext<CallToolRequestParams> 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.Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.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<TextContentBlock>().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"].Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Content?.FirstOrDefault().Value;
var weather = responses["weather"].Deserialize(InputResponse.CreateMessageResultJsonTypeInfo)?.Content
.OfType<TextContentBlock>().FirstOrDefault()?.Text ?? "";
var root = responses["roots"].Deserialize(InputResponse.ListRootsResultJsonTypeInfo)?.Roots?.FirstOrDefault()?.Name ?? "";
// Exception API: single elicitation with requestState
throw new InputRequiredException(
inputRequests: new Dictionary<string, InputRequest>
{
["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 InputRequiredException(
inputRequests: new Dictionary<string, InputRequest>
{
["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(false)]
[InlineData(true)]
public async Task Mrtr_MixedExceptionAndAwaitStyle(bool experimentalClient)
{
// The server always supports DRAFT-2026-v1 (it's in SupportedProtocolVersions). The
// client opts in by pinning ProtocolVersion = "DRAFT-2026-v1"; otherwise it negotiates
// the latest non-draft version and the server falls back to the exception path with
// legacy JSON-RPC resolution.
var messageTracker = ConfigureServer(MrtrMixed);
await using var app = Builder.Build();
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
Action<McpClientOptions> configureClient = experimentalClient
? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; }
: ConfigureMrtrHandlers;
// 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 + 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<McpProtocolException>(() =>
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);
return;
}
if (Stateless && experimentalClient)
{
// 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;
}
// Stateful path - both client modes complete all 3 rounds.
await using var statefulClient = await ConnectAsync(configureClient: configureClient);
Assert.Equal(experimentalClient ? "DRAFT-2026-v1" : "2025-11-25",
statefulClient.NegotiatedProtocolVersion);
var result = await statefulClient.CallToolAsync("mrtr-mixed",
cancellationToken: TestContext.Current.CancellationToken);
var text = Assert.IsType<TextContentBlock>(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)
{
// Rounds 1-2 use wire-format MRTR (InputRequiredResult), but round 3's await calls
// still issue legacy elicitation/create + sampling/createMessage requests, so this
// configuration is mixed-mode.
messageTracker.AssertMrtrUsedAtLeastOnce();
}
else
{
messageTracker.AssertMrtrNotUsed();
}
}
[McpServerTool(Name = "mrtr-parallel-await")]
private static async Task<string> MrtrParallelAwait(McpServer server, CancellationToken ct)
{
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<TextContentBlock>().First().Text}";
}
catch (InvalidOperationException ex)
{
return ex.Message;
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
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).");
ConfigureServer(MrtrParallelAwait);
await using var app = Builder.Build();
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
Action<McpClientOptions> configureClient = experimentalClient
? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; }
: ConfigureMrtrHandlers;
await using var client = await ConnectAsync(configureClient: configureClient);
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.
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
var result = await client.CallToolAsync("mrtr-parallel-await",
cancellationToken: TestContext.Current.CancellationToken);
var text = Assert.IsType<TextContentBlock>(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<TextContentBlock>(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<CallToolRequestParams> context)
{
if (context.Params!.InputResponses is { } responses &&
responses.TryGetValue("user_input", out var response))
{
return $"elicit-ok:{response.Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Action}";
}
throw new InputRequiredException(
inputRequests: new Dictionary<string, InputRequest>
{
["user_input"] = InputRequest.ForElicitation(new ElicitRequestParams
{
Message = "Please confirm",
RequestedSchema = new()
})
},
requestState: "elicit-state");
}
[Fact]
public async Task Mrtr_Roots_CompletesViaMrtr()
{
var messageTracker = ConfigureServer(
[McpServerTool(Name = "mrtr-roots")] (RequestContext<CallToolRequestParams> context) =>
{
if (context.Params!.InputResponses is { } responses &&
responses.TryGetValue("roots", out var response))
{
var roots = response.Deserialize(InputResponse.ListRootsResultJsonTypeInfo)?.Roots;
return $"roots-ok:{string.Join(",", roots?.Select(r => r.Uri) ?? [])}";
}
throw new InputRequiredException(
inputRequests: new Dictionary<string, InputRequest>
{
["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("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
var result = await client.CallToolAsync("mrtr-roots",
cancellationToken: TestContext.Current.CancellationToken);
var text = Assert.IsType<TextContentBlock>(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<CallToolRequestParams> context)
{
var requestState = context.Params!.RequestState;
var inputResponses = context.Params!.InputResponses;
if (requestState == "round-2" && inputResponses is not null)
{
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.ElicitResultJsonTypeInfo)?.Content?.FirstOrDefault().Value;
throw new InputRequiredException(
inputRequests: new Dictionary<string, InputRequest>
{
["greeting"] = InputRequest.ForElicitation(new ElicitRequestParams
{
Message = $"How should I greet {name}?",
RequestedSchema = new()
})
},
requestState: "round-2");
}
throw new InputRequiredException(
inputRequests: new Dictionary<string, InputRequest>
{
["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 = ConfigureServer(MrtrMulti);
await using var app = Builder.Build();
app.MapMcp();
await app.StartAsync(TestContext.Current.CancellationToken);
// Configure client - experimental or default based on parameter.
Action<McpClientOptions> configureClient = experimentalClient
? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; }
: ConfigureMrtrHandlers;
await using var client = await ConnectAsync(configureClient: configureClient);
if (!experimentalClient && Stateless)
{
// Stateless without MRTR: InputRequiredException can't be resolved
// (no MRTR negotiated and no stateful backcompat path).
var ex = await Assert.ThrowsAsync<McpProtocolException>(() =>
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<TextContentBlock>(Assert.Single(result.Content)).Text;
Assert.Equal("multi-done:greeting=accept", text);
Assert.True(result.IsError is not true);
if (experimentalClient)
{
Assert.Equal("DRAFT-2026-v1", 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)
{
ConfigureServer([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<McpClientOptions> configureClient = experimentalClient
? options => { ConfigureMrtrHandlers(options); options.ProtocolVersion = "DRAFT-2026-v1"; }
: ConfigureMrtrHandlers;
await using var client = await ConnectAsync(configureClient: configureClient);
Assert.Equal(experimentalClient ? "DRAFT-2026-v1" : "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<TextContentBlock>(Assert.Single(result.Content)).Text;
Assert.Equal(expected, text);
}
[McpServerTool(Name = "mrtr-concurrent-three")]
private static string MrtrConcurrentThree(RequestContext<CallToolRequestParams> context)
{
if (context.Params!.InputResponses is { Count: 3 } responses &&
responses.ContainsKey("elicit") &&
responses.ContainsKey("sample") &&
responses.ContainsKey("roots"))
{
var elicitAction = responses["elicit"].Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Action;
var sampleText = responses["sample"].Deserialize(InputResponse.CreateMessageResultJsonTypeInfo)?
.Content.OfType<TextContentBlock>().FirstOrDefault()?.Text;
var rootUris = string.Join(",",
responses["roots"].Deserialize(InputResponse.ListRootsResultJsonTypeInfo)?.Roots.Select(r => r.Uri) ?? []);
return $"all-ok:elicit={elicitAction},sample={sampleText},roots={rootUris}";
}
throw new InputRequiredException(
inputRequests: new Dictionary<string, InputRequest>
{
["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 = ConfigureServer(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.ProtocolVersion = "DRAFT-2026-v1";
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("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
var result = await client.CallToolAsync("mrtr-concurrent-three",
cancellationToken: TestContext.Current.CancellationToken);
var text = Assert.IsType<TextContentBlock>(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_LoadShedding_RequestStateOnly_CompletesViaMrtr()
{
var messageTracker = ConfigureServer(
[McpServerTool(Name = "mrtr-loadshed")] (RequestContext<CallToolRequestParams> context) =>
{
if (context.Params!.RequestState is { } state)
{
return $"resumed:{state}";
}
// requestState-only InputRequiredException (no inputRequests)
throw new InputRequiredException(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("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
var result = await client.CallToolAsync("mrtr-loadshed",
cancellationToken: TestContext.Current.CancellationToken);
var text = Assert.IsType<TextContentBlock>(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 = ConfigureServer(
[McpServerTool(Name = "mrtr-roots-backcompat")] (RequestContext<CallToolRequestParams> 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<string, InputRequest>
{
["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<TextContentBlock>(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 = ConfigureServer(
[McpServerTool(Name = "mrtr-multi-input")] (RequestContext<CallToolRequestParams> context) =>
{
if (context.Params!.InputResponses is { } responses &&
responses.TryGetValue("confirm", out var elicitResponse) &&
responses.TryGetValue("summarize", out var sampleResponse))
{
var action = elicitResponse.Deserialize(InputResponse.ElicitResultJsonTypeInfo)?.Action;
var text = sampleResponse.Deserialize(InputResponse.CreateMessageResultJsonTypeInfo)?.Content.OfType<TextContentBlock>().FirstOrDefault()?.Text;
return $"both:{action}:{text}";
}
throw new InputRequiredException(
inputRequests: new Dictionary<string, InputRequest>
{
["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<TextContentBlock>(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;
ConfigureServer(
[McpServerTool(Name = "mrtr-always-incomplete")] (RequestContext<CallToolRequestParams> context) =>
{
// Always throw - never complete
throw new InputRequiredException(
inputRequests: new Dictionary<string, InputRequest>
{
["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<McpProtocolException>(() =>
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.");
ConfigureServer(
[McpServerTool(Name = "mrtr-empty-inputs")] (RequestContext<CallToolRequestParams> context) =>
{
throw new InputRequiredException(
inputRequests: new Dictionary<string, InputRequest>(),
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<McpProtocolException>(() =>
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.");
ConfigureServer(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<McpProtocolException>(() =>
client.CallToolAsync("mrtr-elicit",
cancellationToken: TestContext.Current.CancellationToken).AsTask());
Assert.Equal(McpErrorCode.InternalError, ex.ErrorCode);
}
}

View file

@ -14,7 +14,7 @@ using System.Text.Json.Nodes;
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 @@ public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelI
protected async Task<McpClient> ConnectAsync(
string? path = null,
HttpClientTransportOptions? transportOptions = null,
McpClientOptions? clientOptions = null)
Action<McpClientOptions>? 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 @@ public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelI
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 abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelI
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<TextContentBlock>(Assert.Single(message.Content)).Text);
sampleCount++;
return new CreateMessageResult
{
Assert.NotNull(parameters?.Messages);
var message = Assert.Single(parameters.Messages);
Assert.Equal(Role.User, message.Role);
Assert.Equal("Test prompt for sampling", Assert.IsType<TextContentBlock>(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);
Model = "test-model",
Role = Role.Assistant,
Content = [new TextContentBlock { Text = "Sampling response from client" }],
};
};
});
var result = await mcpClient.CallToolAsync("sampling-tool", new Dictionary<string, object?>
{
@ -375,7 +371,11 @@ public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelI
},
};
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",
@ -385,10 +385,12 @@ public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelI
new Dictionary<string, object?> { ["prompt"] = "Hello" },
cancellationToken: TestContext.Current.CancellationToken);
Assert.Contains("initialize-response", observedMessageTypes);
Assert.Contains("tools-list-response", observedMessageTypes);
Assert.Contains("tool-call-response", observedMessageTypes);
Assert.Contains($"request:{RequestMethods.SamplingCreateMessage}", observedMessageTypes);
// Exact counts catch regressions where the outgoing filter pipeline gets applied more than once
// per outbound message (e.g., SendRequestAsync double-wrapping SendToRelatedTransportAsync).
Assert.Equal(1, observedMessageTypes.Count(m => m == "initialize-response"));
Assert.Equal(1, observedMessageTypes.Count(m => m == "tools-list-response"));
Assert.Equal(2, observedMessageTypes.Count(m => m == "tool-call-response")); // one per CallToolAsync
Assert.Equal(2, observedMessageTypes.Count(m => m == $"request:{RequestMethods.SamplingCreateMessage}")); // sampling-tool makes two SampleAsync calls
}
[Fact]
@ -496,6 +498,7 @@ public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelI
Assert.Equal("injected", extraMessage);
}
private ClaimsPrincipal CreateUser(string name)
=> new(new ClaimsIdentity(
[new Claim("name", name), new Claim(ClaimTypes.NameIdentifier, name)],
@ -566,4 +569,5 @@ public abstract class MapMcpTests(ITestOutputHelper testOutputHelper) : KestrelI
return $"Operation completed after {durationMs}ms";
}
}
}

View file

@ -0,0 +1,469 @@
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;
/// <summary>
/// Protocol-level tests for Multi Round-Trip Requests (MRTR).
/// These tests send raw JSON-RPC requests via HTTP and verify protocol-level behavior
/// including InputRequiredResult structure, retry with inputResponses, and error handling.
/// </summary>
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.ProtocolVersion = "DRAFT-2026-v1";
}).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 InputRequiredResult
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var sseData = Assert.Single(await ReadSseAsync(response.Content).ToListAsync(TestContext.Current.CancellationToken));
var message = JsonSerializer.Deserialize<JsonRpcMessage>(sseData, McpJsonUtilities.DefaultOptions);
var error = Assert.IsType<JsonRpcError>(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<JsonRpcMessage>(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}");
}
[Fact]
public async Task SessionDelete_CancelsPendingMrtrContinuation()
{
await StartAsync();
await InitializeWithMrtrAsync();
// 1. Call a tool that suspends at ElicitAsync (implicit 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<JsonObject>(rpcResponse.Result);
Assert.Equal("input_required", resultObj["resultType"]?.GetValue<string>());
var requestState = resultObj["requestState"]!.GetValue<string>();
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<JsonObject>(rpcResponse.Result);
var requestState = resultObj["requestState"]!.GetValue<string>();
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);
}
/// <summary>
/// Regression test for a CI hang where the server-side MRTR backcompat resolver routed its
/// outgoing <c>roots/list</c> 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
/// <see cref="ModelContextProtocol.Protocol.JsonRpcMessageContext.RelatedTransport"/>) 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 <c>tools/call</c>, and the <c>roots/list</c> response. If the
/// server falls back to <c>_transport.SendMessageAsync</c>, the test times out instead of
/// reading the expected <c>roots/list</c> SSE event off the <c>tools/call</c> POST response.
/// </summary>
[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<CallToolRequestParams> 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<string, InputRequest>
{
["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<string>());
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<string>());
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<JsonRpcResponse>());
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<string>());
}
finally
{
await sseEvents.DisposeAsync();
}
}
// --- Helpers ---
private static StringContent JsonContent(string json) => new(json, Encoding.UTF8, "application/json");
private static JsonTypeInfo<T> GetJsonTypeInfo<T>() => (JsonTypeInfo<T>)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T));
private static async IAsyncEnumerable<string> 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<JsonRpcResponse> 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<JsonRpcResponse>());
Assert.NotNull(jsonRpcResponse);
return jsonRpcResponse;
}
private Task<HttpResponseMessage> 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<string>() 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<string>(),
"resources/read" => paramsObj["uri"]?.GetValue<string>(),
_ => null,
};
if (mcpName is not null)
{
content.Headers.Add("Mcp-Name", mcpName);
}
}
}
}
return HttpClient.PostAsync("", content, 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}}}
""");
/// <summary>
/// Initialize a session requesting the experimental protocol version that enables MRTR.
/// </summary>
private async Task InitializeWithMrtrAsync()
{
var initJson = """
{"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);
var rpcResponse = await AssertSingleSseResponseAsync(response);
Assert.NotNull(rpcResponse.Result);
// Verify the server negotiated to the experimental version
var protocolVersion = rpcResponse.Result["protocolVersion"]?.GetValue<string>();
Assert.Equal("DRAFT-2026-v1", 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", "DRAFT-2026-v1");
// Reset request ID counter since initialize used ID 1
_lastRequestId = 1;
}
}

View file

@ -159,6 +159,34 @@ public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutpu
$"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);

View file

@ -31,6 +31,7 @@ public class Program
.WithHttpTransport()
.WithDistributedCacheEventStreamStore()
.WithTools<ConformanceTools>()
.WithTools<IncompleteResultTools>()
.WithTools([ConformanceTools.CreateJsonSchema202012Tool()])
.WithRequestFilters(filters => filters.AddCallToolFilter(next => async (request, cancellationToken) =>
{
@ -47,6 +48,7 @@ public class Program
return result;
}))
.WithPrompts<ConformancePrompts>()
.WithPrompts<IncompleteResultPrompts>()
.WithResources<ConformanceResources>()
.WithSubscribeToResourcesHandler(async (ctx, ct) =>
{

View file

@ -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;
/// <summary>
/// Prompt implementing the SEP-2322 D1 conformance scenario (<c>incomplete-result-non-tool-request</c>),
/// proving that <c>prompts/get</c> can return an <see cref="InputRequiredResult"/> just like
/// <c>tools/call</c>.
/// </summary>
[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<GetPromptRequestParams> context)
{
if (context.Params!.InputResponses is { } responses &&
responses.TryGetValue("user_context", out var response))
{
var elicit = response.Deserialize(InputResponse.ElicitResultJsonTypeInfo);
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<string, InputRequest>
{
["user_context"] = InputRequest.ForElicitation(new ElicitRequestParams
{
Message = "What context should the prompt use?",
RequestedSchema = new ElicitRequestParams.RequestSchema
{
Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition>
{
["context"] = new ElicitRequestParams.StringSchema(),
},
Required = ["context"],
},
}),
});
}
private static string? TryReadString(IDictionary<string, JsonElement>? content, string key)
{
if (content is null || !content.TryGetValue(key, out var element))
{
return null;
}
return element.ValueKind == JsonValueKind.String ? element.GetString() : element.ToString();
}
}

View file

@ -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;
/// <summary>
/// Tools implementing the SEP-2322 (MRTR / IncompleteResult) conformance scenarios from
/// <c>incomplete-result.ts</c> in the conformance test suite. All tools use the
/// <see cref="InputRequiredException"/> 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).
/// </summary>
[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<CallToolRequestParams> context)
{
if (context.Params!.InputResponses is { } responses &&
responses.TryGetValue("user_name", out var response))
{
var elicit = response.Deserialize(InputResponse.ElicitResultJsonTypeInfo);
var name = TryReadString(elicit?.Content, "name") ?? "world";
return TextResult($"Hello, {name}!");
}
throw new InputRequiredException(
new Dictionary<string, InputRequest>
{
["user_name"] = InputRequest.ForElicitation(new ElicitRequestParams
{
Message = "What is your name?",
RequestedSchema = new ElicitRequestParams.RequestSchema
{
Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition>
{
["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<CallToolRequestParams> context)
{
if (context.Params!.InputResponses is { } responses &&
responses.TryGetValue("capital_question", out var response))
{
var text = response.Deserialize(InputResponse.CreateMessageResultJsonTypeInfo)?.Content?.OfType<TextContentBlock>().FirstOrDefault()?.Text ?? "(no text)";
return TextResult($"Sampling said: {text}");
}
throw new InputRequiredException(
new Dictionary<string, InputRequest>
{
["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<CallToolRequestParams> context)
{
if (context.Params!.InputResponses is { } responses &&
responses.TryGetValue("client_roots", out var response))
{
var count = response.Deserialize(InputResponse.ListRootsResultJsonTypeInfo)?.Roots?.Count ?? 0;
return TextResult($"Got {count} root(s) from the client.");
}
throw new InputRequiredException(
new Dictionary<string, InputRequest>
{
["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<CallToolRequestParams> 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<string, InputRequest>
{
["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams
{
Message = "Please confirm",
RequestedSchema = new ElicitRequestParams.RequestSchema
{
Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition>
{
["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<CallToolRequestParams> 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<string, InputRequest>
{
["user_name"] = InputRequest.ForElicitation(new ElicitRequestParams
{
Message = "What is your name?",
RequestedSchema = new ElicitRequestParams.RequestSchema
{
Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition>
{
["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<CallToolRequestParams> context)
{
var state = context.Params!.RequestState;
if (state is null)
{
// Round 1: elicit name.
throw new InputRequiredException(
inputRequests: new Dictionary<string, InputRequest>
{
["step1"] = InputRequest.ForElicitation(new ElicitRequestParams
{
Message = "Step 1: What is your name?",
RequestedSchema = new ElicitRequestParams.RequestSchema
{
Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition>
{
["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<string, InputRequest>
{
["step2"] = InputRequest.ForElicitation(new ElicitRequestParams
{
Message = "Step 2: What is your favorite color?",
RequestedSchema = new ElicitRequestParams.RequestSchema
{
Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition>
{
["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<CallToolRequestParams> context)
{
if (context.Params!.InputResponses is { } responses &&
responses.TryGetValue("user_name", out var response))
{
var elicit = response.Deserialize(InputResponse.ElicitResultJsonTypeInfo);
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<string, InputRequest>
{
["user_name"] = InputRequest.ForElicitation(new ElicitRequestParams
{
Message = "What is your name?",
RequestedSchema = new ElicitRequestParams.RequestSchema
{
Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition>
{
["name"] = new ElicitRequestParams.StringSchema(),
},
Required = ["name"],
},
}),
});
}
private static CallToolResult TextResult(string text) => new()
{
Content = [new TextContentBlock { Text = text }],
};
private static string? TryReadString(IDictionary<string, JsonElement>? content, string key)
{
if (content is null || !content.TryGetValue(key, out var element))
{
return null;
}
return element.ValueKind == JsonValueKind.String ? element.GetString() : element.ToString();
}
}

View file

@ -587,6 +587,14 @@ public class McpClientTests : ClientServerTestBase
Assert.Equal(protocolVersion ?? "2025-11-25", client.NegotiatedProtocolVersion);
}
[Fact]
public async Task ReturnsNegotiatedProtocolVersion_WithExperimentalProtocol()
{
Server.ServerOptions.ProtocolVersion = "DRAFT-2026-v1";
await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = "DRAFT-2026-v1" });
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
}
[Fact]
public async Task EndToEnd_SamplingWithTools_ServerUsesIChatClientWithFunctionInvocation_ClientHandlesSamplingWithIChatClient()
{

View file

@ -0,0 +1,570 @@
#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;
/// <summary>
/// Edge-case and guardrail tests for MRTR over in-memory pipe transport. These focus on
/// scenarios not easily covered by <see cref="T:ModelContextProtocol.AspNetCore.Tests.MapMcpTests"/>
/// which provides broad happy-path coverage across StreamableHttp, SSE, and Stateless transports.
/// </summary>
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<McpServerOptions>(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) =>
{
// 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 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<InvalidOperationException>(async () =>
await client.CallToolAsync("elicitation-tool",
new Dictionary<string, object?> { ["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<ElicitResult>(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<TextContentBlock>(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<ElicitResult>(new ElicitResult { Action = "accept" });
clientOptions.Handlers.SamplingHandler = (request, progress, ct) =>
new ValueTask<CreateMessageResult>(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<JsonRpcRequest>(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<JsonRpcResponse>(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<ElicitResult>(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<ElicitResult>(new ElicitResult
{
Action = "accept",
Content = new Dictionary<string, JsonElement>
{
["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<JsonRpcRequest>(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<JsonRpcRequest>(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<JsonRpcRequest>(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<string>());
// 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<CallToolResult>(response.Result, McpJsonUtilities.DefaultOptions);
Assert.NotNull(result);
var content = Assert.Single(result.Content);
Assert.Equal("completed-without-mrtr", Assert.IsType<TextContentBlock>(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();
}
[Fact]
public async Task IncompleteResultRetry_OmittingRequestState_StripsStaleStateFromRetryParams()
{
// Regression test for #1458 review feedback: when the server returns InputRequiredResult
// with requestState on round 1 and then InputRequiredResult WITHOUT requestState on round 2,
// the client's third retry must NOT carry the stale round-1 requestState forward via the
// params deep clone. Without the fix, the third retry's params contain {"requestState": "round1-state"}
// even though the round-2 InputRequiredResult cleared it.
StartServer(); // base-class disposal hook
var clientToServer = new Pipe();
var serverToClient = new Pipe();
var clientOptions = new McpClientOptions();
clientOptions.Handlers.ElicitationHandler = (_, _) =>
new ValueTask<ElicitResult>(new ElicitResult
{
Action = "accept",
Content = new Dictionary<string, JsonElement>
{
["confirmed"] = JsonDocument.Parse("\"yes\"").RootElement.Clone()
}
});
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();
// Initialize handshake - negotiate DRAFT-2026-v1 so the client treats InputRequiredResult as MRTR.
var initLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken);
Assert.NotNull(initLine);
var initRequest = JsonSerializer.Deserialize<JsonRpcRequest>(initLine, McpJsonUtilities.DefaultOptions);
Assert.NotNull(initRequest);
Assert.Equal("initialize", initRequest.Method);
var initResponse = new JsonRpcResponse
{
Id = initRequest.Id,
Result = JsonSerializer.SerializeToNode(new InitializeResult
{
ProtocolVersion = "DRAFT-2026-v1",
Capabilities = new ServerCapabilities { Tools = new() },
ServerInfo = new Implementation { Name = "MrtrServer", Version = "1.0" }
}, McpJsonUtilities.DefaultOptions),
};
await WriteJsonRpcAsync(serverWriter, initResponse);
var initializedLine = await serverReader.ReadLineAsync(TestContext.Current.CancellationToken);
Assert.NotNull(initializedLine);
await using var client = await clientTask;
Assert.Equal("DRAFT-2026-v1", client.NegotiatedProtocolVersion);
var cancellationToken = TestContext.Current.CancellationToken;
// Capture the retry payloads sent by the client so we can inspect them after the call completes.
JsonObject? retry1Params = null;
JsonObject? retry2Params = null;
var serverLoop = Task.Run(async () =>
{
// --- Round 1: receive original tools/call, respond with InputRequiredResult + requestState="round1-state".
var call1Line = await serverReader.ReadLineAsync(cancellationToken);
Assert.NotNull(call1Line);
var call1 = JsonSerializer.Deserialize<JsonRpcRequest>(call1Line, McpJsonUtilities.DefaultOptions);
Assert.NotNull(call1);
Assert.Equal("tools/call", call1.Method);
var round1Result = new JsonObject
{
["resultType"] = "input_required",
["inputRequests"] = new JsonObject
{
["q1"] = JsonSerializer.SerializeToNode(
InputRequest.ForElicitation(new ElicitRequestParams { Message = "round1", RequestedSchema = new() }),
McpJsonUtilities.DefaultOptions),
},
["requestState"] = "round1-state",
};
await WriteJsonRpcAsync(serverWriter, new JsonRpcResponse { Id = call1.Id, Result = round1Result });
// --- Round 2: receive first retry (should include requestState="round1-state" + inputResponses).
var call2Line = await serverReader.ReadLineAsync(cancellationToken);
Assert.NotNull(call2Line);
var call2 = JsonSerializer.Deserialize<JsonRpcRequest>(call2Line, McpJsonUtilities.DefaultOptions);
Assert.NotNull(call2);
retry1Params = call2.Params as JsonObject;
// Respond with another InputRequiredResult - this time WITHOUT requestState - to force the
// client to clear any stale state on the next retry params clone.
var round2Result = new JsonObject
{
["resultType"] = "input_required",
["inputRequests"] = new JsonObject
{
["q2"] = JsonSerializer.SerializeToNode(
InputRequest.ForElicitation(new ElicitRequestParams { Message = "round2", RequestedSchema = new() }),
McpJsonUtilities.DefaultOptions),
},
// Intentionally NO "requestState" key.
};
await WriteJsonRpcAsync(serverWriter, new JsonRpcResponse { Id = call2.Id, Result = round2Result });
// --- Round 3: receive second retry - assertion target. Must NOT contain "requestState".
var call3Line = await serverReader.ReadLineAsync(cancellationToken);
Assert.NotNull(call3Line);
var call3 = JsonSerializer.Deserialize<JsonRpcRequest>(call3Line, McpJsonUtilities.DefaultOptions);
Assert.NotNull(call3);
retry2Params = call3.Params as JsonObject;
// Final success response so the client's call completes cleanly.
await WriteJsonRpcAsync(serverWriter, new JsonRpcResponse
{
Id = call3.Id,
Result = JsonSerializer.SerializeToNode(new CallToolResult
{
Content = [new TextContentBlock { Text = "done" }]
}, McpJsonUtilities.DefaultOptions),
});
}, cancellationToken);
var response = await client.SendRequestAsync(
new JsonRpcRequest
{
Method = "tools/call",
Params = JsonSerializer.SerializeToNode(new CallToolRequestParams { Name = "any-tool" }, McpJsonUtilities.DefaultOptions),
},
cancellationToken);
await serverLoop;
// Sanity check the final result reached us.
Assert.NotNull(response.Result);
var result = JsonSerializer.Deserialize<CallToolResult>(response.Result, McpJsonUtilities.DefaultOptions);
Assert.NotNull(result);
Assert.Equal("done", Assert.IsType<TextContentBlock>(Assert.Single(result.Content)).Text);
// The first retry must carry requestState="round1-state".
Assert.NotNull(retry1Params);
Assert.NotNull(retry1Params!["inputResponses"]);
Assert.Equal("round1-state", retry1Params["requestState"]?.GetValue<string>());
// The second retry must NOT carry a stale requestState. Pre-fix, the deep clone of the
// round-1 request kept "round1-state" in paramsObj because the client only OVERWROTE it
// when InputRequiredResult.RequestState was non-null. With the fix, it explicitly removes
// the key whenever the server's new InputRequiredResult clears it.
Assert.NotNull(retry2Params);
Assert.NotNull(retry2Params!["inputResponses"]);
Assert.False(retry2Params.ContainsKey("requestState"),
"Retry params must not carry a stale requestState from the previous round.");
clientToServer.Writer.Complete();
serverToClient.Writer.Complete();
}
private static async Task WriteJsonRpcAsync(Stream writer, JsonRpcMessage message)
{
var bytes = JsonSerializer.SerializeToUtf8Bytes<JsonRpcMessage>(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

View file

@ -87,8 +87,14 @@ public class McpServerBuilderExtensionsMessageFilterTests(ITestOutputHelper test
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
// The message filter should intercept JsonRpcRequest messages
Assert.Contains("JsonRpcRequest", messageTypes);
// The message filter should intercept JsonRpcRequest messages.
// Use strict counts so a regression that invokes the filter pipeline more than once per
// incoming message (analogous to the SendRequestAsync double-wrap regression on the outgoing
// side) would fail this test instead of slipping through Assert.Contains.
// A single ListToolsAsync drives three server-bound messages: initialize (request),
// notifications/initialized (notification), and tools/list (request).
Assert.Equal(2, messageTypes.Count(m => m == nameof(JsonRpcRequest)));
Assert.Equal(1, messageTypes.Count(m => m == nameof(JsonRpcNotification)));
}
[Fact]
@ -142,6 +148,13 @@ public class McpServerBuilderExtensionsMessageFilterTests(ITestOutputHelper test
Assert.True(idx1Before < idx2Before);
Assert.True(idx2Before < idx2After);
Assert.True(idx2After < idx1After);
// Verify each filter ran exactly once per incoming message (initialize + notifications/initialized + tools/list).
// Strict counts catch regressions where the incoming filter pipeline gets invoked more than once per message.
Assert.Equal(3, logMessages.Count(m => m == "MessageFilter1 before"));
Assert.Equal(3, logMessages.Count(m => m == "MessageFilter2 before"));
Assert.Equal(3, logMessages.Count(m => m == "MessageFilter2 after"));
Assert.Equal(3, logMessages.Count(m => m == "MessageFilter1 after"));
}
[Fact]
@ -372,15 +385,20 @@ public class McpServerBuilderExtensionsMessageFilterTests(ITestOutputHelper test
await client.CallToolAsync("sampling-tool", new Dictionary<string, object?> { ["prompt"] = "Hello" },
cancellationToken: TestContext.Current.CancellationToken);
// Exact counts catch regressions where the outgoing filter pipeline gets applied more than once
// per outbound message (e.g., SendRequestAsync double-wrapping SendToRelatedTransportAsync).
Assert.Equal(1, observedMessages.Count(m => m == "initialize"));
Assert.Equal(2, observedMessages.Count(m => m == "progress")); // ProgressTool sends two NotifyProgressAsync calls
Assert.Equal(2, observedMessages.Count(m => m == "response")); // one tool-call response per CallToolAsync
Assert.Equal(1, observedMessages.Count(m => m == $"request:{RequestMethods.SamplingCreateMessage}"));
// Preserve the original ordering intent: initialize first, then progress, then the final response.
int initializeIndex = observedMessages.IndexOf("initialize");
int progressIndex = observedMessages.IndexOf("progress");
int responseIndex = observedMessages.LastIndexOf("response");
int requestIndex = observedMessages.IndexOf($"request:{RequestMethods.SamplingCreateMessage}");
Assert.True(initializeIndex >= 0);
Assert.True(progressIndex > initializeIndex);
Assert.True(responseIndex > progressIndex);
Assert.True(requestIndex >= 0);
}
[Fact]
@ -516,7 +534,7 @@ public class McpServerBuilderExtensionsMessageFilterTests(ITestOutputHelper test
McpServerBuilder
.WithMessageFilters(filters => filters.AddIncomingFilter((next) => (context, cancellationToken) =>
{
// Skip processing tools/list requests — handler never runs, no response sent
// Skip processing tools/list requests - handler never runs, no response sent
if (context.JsonRpcMessage is JsonRpcRequest request && request.Method == RequestMethods.ToolsList)
{
return Task.CompletedTask;
@ -552,7 +570,7 @@ public class McpServerBuilderExtensionsMessageFilterTests(ITestOutputHelper test
McpServerBuilder
.WithMessageFilters(filters => filters.AddIncomingFilter((next) => (context, cancellationToken) =>
{
// Pass through — handler runs, response is sent
// Pass through - handler runs, response is sent
return next(context, cancellationToken);
}))
.WithTools<TestTool>();

View file

@ -0,0 +1,298 @@
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 InputRequiredResult
{
InputRequests = new Dictionary<string, InputRequest>
{
["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<InputRequiredResult>(json, McpJsonUtilities.DefaultOptions);
Assert.NotNull(deserialized);
Assert.Equal("input_required", 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 InputRequiredResult();
Assert.Equal("input_required", result.ResultType);
}
[Fact]
public static void IncompleteResult_ResultType_AppearsInJson()
{
var result = new InputRequiredResult
{
RequestState = "abc",
};
string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions);
var node = JsonNode.Parse(json);
Assert.NotNull(node);
Assert.Equal("input_required", (string?)node["resultType"]);
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<InputRequest>(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<InputRequest>(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<InputRequest>(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<InputResponse>(json, McpJsonUtilities.DefaultOptions);
Assert.NotNull(deserialized);
var sampling = deserialized.Deserialize(InputResponse.CreateMessageResultJsonTypeInfo);
Assert.NotNull(sampling);
Assert.Equal("test-model", sampling.Model);
}
[Fact]
public static void InputResponse_FromElicitResult_RoundTrip()
{
var elicitResult = new ElicitResult
{
Action = "confirm",
Content = new Dictionary<string, JsonElement>
{
["key"] = JsonDocument.Parse("\"value\"").RootElement.Clone()
}
};
var inputResponse = InputResponse.FromElicitResult(elicitResult);
string json = JsonSerializer.Serialize(inputResponse, McpJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<InputResponse>(json, McpJsonUtilities.DefaultOptions);
Assert.NotNull(deserialized);
var elicit = deserialized.Deserialize(InputResponse.ElicitResultJsonTypeInfo);
Assert.NotNull(elicit);
Assert.Equal("confirm", elicit.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<InputResponse>(json, McpJsonUtilities.DefaultOptions);
Assert.NotNull(deserialized);
var roots = deserialized.Deserialize(InputResponse.ListRootsResultJsonTypeInfo);
Assert.NotNull(roots);
Assert.Single(roots.Roots);
Assert.Equal("file:///test", roots.Roots[0].Uri);
}
[Fact]
public static void InputRequestDictionary_SerializationRoundTrip()
{
IDictionary<string, InputRequest> requests = new Dictionary<string, InputRequest>
{
["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<IDictionary<string, InputRequest>>(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<string, InputResponse> responses = new Dictionary<string, InputResponse>
{
["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<IDictionary<string, InputResponse>>(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?["resultType"]);
}
[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"]);
}
}

View file

@ -0,0 +1,151 @@
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace ModelContextProtocol.Tests.Server;
/// <summary>
/// Verifies that the server-to-client request methods (<see cref="McpServer.ElicitAsync(ElicitRequestParams, CancellationToken)"/>,
/// <see cref="McpServer.SampleAsync(CreateMessageRequestParams, CancellationToken)"/>,
/// <see cref="McpServer.RequestRootsAsync"/>) keep working when the negotiated protocol revision is
/// <c>DRAFT-2026-v1</c> on a stateful session - for example, stdio.
/// </summary>
/// <remarks>
/// Under <c>DRAFT-2026-v1</c> the spec removes the corresponding server-to-client request methods, but
/// the SDK only fails fast in stateless mode (where the existing <c>ThrowIf*Unsupported</c> guards already
/// throw "X is not supported in stateless mode" because <see cref="McpServer.ClientCapabilities"/> is
/// <see langword="null"/>). Stdio is implicitly stateful - one <see cref="McpServer"/> per process - so the
/// legacy <c>elicitation/create</c> / <c>sampling/createMessage</c> / <c>roots/list</c> flow still works.
/// A future PR is expected to force <c>DRAFT-2026-v1</c> Streamable HTTP servers to stateless mode, at which
/// point those configurations will start throwing through the existing stateless guard.
/// </remarks>
public sealed class DraftProtocolBackcompatTests : ClientServerTestBase
{
public DraftProtocolBackcompatTests(ITestOutputHelper testOutputHelper)
: base(testOutputHelper, startServer: false)
{
}
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
{
services.Configure<McpServerOptions>(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<ElicitResult>(new ElicitResult { Action = "accept" }),
},
});
var result = await client.CallToolAsync("elicit-tool", cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("elicit-ok:accept", Assert.IsType<TextContentBlock>(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<CreateMessageResult>(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<TextContentBlock>(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<ListRootsResult>(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<TextContentBlock>(result.Content[0]).Text);
}
private static async Task<string> 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<string> 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<TextContentBlock>().FirstOrDefault()?.Text;
return $"sample-ok:{text}";
}
private static async Task<string> RootsToolAsync(McpServer server, CancellationToken cancellationToken)
{
var roots = await server.RequestRootsAsync(new ListRootsRequestParams(), cancellationToken);
return $"roots-ok:{roots.Roots.FirstOrDefault()?.Uri}";
}
}

View file

@ -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;
/// <summary>
/// Tests for the server's MRTR handler lifecycle management - cancellation, disposal, and error
/// logging during multi round-trip request processing.
/// </summary>
public class MrtrHandlerLifecycleTests : ClientServerTestBase
{
private readonly TaskCompletionSource<bool> _handlerTokenCancelled = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource<bool> _handlerStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource<bool> _handlerResumed = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource<bool> _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<McpServerOptions>(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<bool>)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<string, InputRequest>
{
["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<ElicitResult>(new ElicitResult { Action = "accept" });
};
await using var client = await CreateMcpClientForServer(clientOptions);
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
await client.CallToolAsync("elicitation-tool",
new Dictionary<string, object?> { ["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<bool>(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<Exception>(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<ElicitResult>(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<string, object?> { ["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<OperationCanceledException>(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<ElicitResult>(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<TextContentBlock>().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<ElicitResult>(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<string, object?> { ["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<ElicitResult>(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<ElicitResult>(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<McpException>(() => 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();
}
}

View file

@ -0,0 +1,61 @@
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
namespace ModelContextProtocol.Tests.Server;
/// <summary>
/// Tests for the MRTR server API - IsMrtrSupported, InputRequiredException,
/// and client auto-retry of incomplete results.
/// </summary>
public class MrtrInputRequiredExceptionTests : ClientServerTestBase
{
private readonly ServerMessageTracker _messageTracker = new();
public MrtrInputRequiredExceptionTests(ITestOutputHelper testOutputHelper)
: base(testOutputHelper, startServer: false)
{
}
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
{
services.Configure<McpServerOptions>(options =>
{
options.ProtocolVersion = "DRAFT-2026-v1";
_messageTracker.AddFilters(options.Filters.Message);
});
mcpServerBuilder.WithTools([
McpServerTool.Create(
static string (McpServer server) =>
{
throw new InputRequiredException(requestState: "should-not-work");
},
new McpServerToolCreateOptions
{
Name = "always-incomplete",
Description = "Tool that always throws InputRequiredException"
}),
]);
}
[Fact]
public async Task InputRequiredException_WithoutInputRequests_ExhaustsRetries()
{
StartServer();
var clientOptions = new McpClientOptions();
await using var client = await CreateMcpClientForServer(clientOptions);
// The always-incomplete tool throws InputRequiredException with only requestState
// 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<McpException>(() =>
client.CallToolAsync("always-incomplete",
cancellationToken: TestContext.Current.CancellationToken).AsTask());
Assert.Contains("more than", exception.Message);
}
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
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<McpServerOptions>(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<TextContentBlock>().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<ElicitResult>(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<string, object?> { ["message"] = "test" },
cancellationToken: TestContext.Current.CancellationToken);
var content = Assert.Single(result.Content);
Assert.Equal("accept", Assert.IsType<TextContentBlock>(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<TextContentBlock>().FirstOrDefault()?.Text;
return new ValueTask<CreateMessageResult>(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<string, object?> { ["prompt"] = "test" },
cancellationToken: TestContext.Current.CancellationToken);
var content = Assert.Single(result.Content);
Assert.Equal("Sampled: test", Assert.IsType<TextContentBlock>(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<ElicitResult>(new ElicitResult { Action = "accept" });
};
await using var client = await CreateMcpClientForServer(clientOptions);
await client.CallToolAsync("elicit-tool",
new Dictionary<string, object?> { ["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();
}
}

View file

@ -0,0 +1,113 @@
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
namespace ModelContextProtocol.Tests.Server;
/// <summary>
/// Tests for the legacy MRTR backcompat resolver in <c>McpServerImpl.InvokeWithInputRequiredResultHandlingAsync</c>.
/// This path runs only when the client did NOT negotiate MRTR (DRAFT-2026-v1) and the session is stateful -
/// the server dispatches each input request to the client via standard JSON-RPC and re-invokes the handler
/// with the merged responses. To exercise it the server must NOT pin a protocol version; the client picks
/// a non-draft version during initialize negotiation.
/// </summary>
public class MrtrServerBackcompatTests : ClientServerTestBase
{
private readonly List<string?> _observedRequestStates = [];
private int _attempt;
public MrtrServerBackcompatTests(ITestOutputHelper testOutputHelper)
: base(testOutputHelper, startServer: false)
{
}
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
{
mcpServerBuilder.WithTools([
McpServerTool.Create(
(RequestContext<CallToolRequestParams> context) =>
{
var attempt = Interlocked.Increment(ref _attempt);
_observedRequestStates.Add(context.Params?.RequestState);
return attempt switch
{
// Round 1: caller has no state; emit one and request elicitation.
1 => throw new InputRequiredException(
inputRequests: new Dictionary<string, InputRequest>
{
["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams
{
Message = "round1",
RequestedSchema = new()
})
},
requestState: "round1"),
// Round 2: deliberately clear the state by passing requestState: null while still
// asking for another elicitation. This exercises the params clone path that
// previously preserved the stale "round1" carry-over from round 1's deep clone.
2 => throw new InputRequiredException(
inputRequests: new Dictionary<string, InputRequest>
{
["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams
{
Message = "round2",
RequestedSchema = new()
})
},
requestState: null),
// Round 3 (final): report what the handler observed so the test can assert it.
_ => $"final-state:{context.Params?.RequestState ?? "<null>"}",
};
},
new McpServerToolCreateOptions
{
Name = "requeststate-transition",
Description = "Tool that transitions requestState from set to null across MRTR rounds."
}),
]);
}
[Fact]
public async Task InputRequiredException_TransitioningRequestStateToNull_DoesNotLeakStaleState()
{
StartServer();
// Non-MRTR client → server falls into the legacy backcompat resolver path on InputRequiredException.
var clientOptions = new McpClientOptions
{
ProtocolVersion = "2025-06-18",
Capabilities = new ClientCapabilities { Elicitation = new() },
};
clientOptions.Handlers.ElicitationHandler = (_, _) =>
new ValueTask<ElicitResult>(new ElicitResult
{
Action = "accept",
Content = new Dictionary<string, JsonElement>
{
["answer"] = JsonDocument.Parse("\"ok\"").RootElement,
},
});
await using var client = await CreateMcpClientForServer(clientOptions);
var result = await client.CallToolAsync(
"requeststate-transition",
cancellationToken: TestContext.Current.CancellationToken);
// Three attempts: round 1 (no state) → round 2 (state="round1") → round 3 (state=null after fix).
// Without the fix, the third observed state would erroneously remain "round1" because the deep-clone
// of the prior request params carried it forward when InputRequiredException.RequestState was null.
Assert.Equal(3, _observedRequestStates.Count);
Assert.Null(_observedRequestStates[0]);
Assert.Equal("round1", _observedRequestStates[1]);
Assert.Null(_observedRequestStates[2]);
var content = Assert.Single(result.Content);
var text = Assert.IsType<TextContentBlock>(content).Text;
Assert.Equal("final-state:<null>", text);
}
}

Some files were not shown because too many files have changed in this diff Show more