Multi Round-Trip Requests (MRTR) (#1458)
This commit is contained in:
parent
8202bcc932
commit
f6dbe4372f
21 changed files with 5832 additions and 103 deletions
|
|
@ -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))
|
||||
|
|
|
|||
95
tests/Common/Utils/ServerMessageTracker.cs
Normal file
95
tests/Common/Utils/ServerMessageTracker.cs
Normal 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<McpServerOptions></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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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().
|
||||
|
|
|
|||
779
tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs
Normal file
779
tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.Mrtr.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
469
tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs
Normal file
469
tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
570
tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs
Normal file
570
tests/ModelContextProtocol.Tests/Client/MrtrIntegrationTests.cs
Normal 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
|
||||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}";
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue