Multi Round-Trip Requests (MRTR) (#1458)
This commit is contained in:
parent
8202bcc932
commit
f6dbe4372f
6 changed files with 5832 additions and 103 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
183
tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs
Normal file
183
tests/ModelContextProtocol.Tests/Server/MrtrSessionLimitTests.cs
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using ModelContextProtocol.Tests.Utils;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for session-scoped MRTR resource governance - verifying that outgoing message
|
||||
/// filters can track and limit MRTR round trips per session.
|
||||
/// </summary>
|
||||
public class MrtrSessionLimitTests : ClientServerTestBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Tracks the number of pending MRTR flows per session. Incremented when an InputRequiredResult
|
||||
/// is sent (outgoing filter), decremented when a retry with requestState arrives (incoming filter).
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<string, int> _pendingFlowsPerSession = new();
|
||||
|
||||
/// <summary>
|
||||
/// Records every (sessionId, pendingCount) observation from the outgoing filter,
|
||||
/// so the test can verify the tracking was correct.
|
||||
/// </summary>
|
||||
private readonly ConcurrentBag<(string SessionId, int PendingCount)> _observations = [];
|
||||
|
||||
private readonly ServerMessageTracker _messageTracker = new();
|
||||
|
||||
/// <summary>
|
||||
/// Maximum allowed concurrent MRTR flows per session. If exceeded, the outgoing filter
|
||||
/// replaces the InputRequiredResult with an error response.
|
||||
/// </summary>
|
||||
private int _maxFlowsPerSession = int.MaxValue;
|
||||
|
||||
/// <summary>
|
||||
/// Counts how many IncompleteResults were blocked by the per-session limit.
|
||||
/// </summary>
|
||||
private int _blockedFlowCount;
|
||||
|
||||
public MrtrSessionLimitTests(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);
|
||||
|
||||
// Outgoing filter: detect InputRequiredResult responses and track per session.
|
||||
options.Filters.Message.OutgoingFilters.Add(next => async (context, cancellationToken) =>
|
||||
{
|
||||
if (context.JsonRpcMessage is JsonRpcResponse response &&
|
||||
response.Result is JsonObject resultObj &&
|
||||
resultObj.TryGetPropertyValue("resultType", out var resultTypeNode) &&
|
||||
resultTypeNode?.GetValue<string>() is "input_required")
|
||||
{
|
||||
var sessionId = context.Server.SessionId ?? "unknown";
|
||||
var newCount = _pendingFlowsPerSession.AddOrUpdate(sessionId, 1, (_, c) => c + 1);
|
||||
_observations.Add((sessionId, newCount));
|
||||
|
||||
// Enforce per-session limit: if exceeded, replace the InputRequiredResult
|
||||
// with a JSON-RPC error. This prevents the client from receiving the
|
||||
// InputRequiredResult and starting another retry cycle.
|
||||
if (newCount > _maxFlowsPerSession)
|
||||
{
|
||||
// Undo the increment since we're blocking this flow.
|
||||
_pendingFlowsPerSession.AddOrUpdate(sessionId, 0, (_, c) => Math.Max(0, c - 1));
|
||||
Interlocked.Increment(ref _blockedFlowCount);
|
||||
|
||||
// Replace the outgoing message with a JSON-RPC error.
|
||||
context.JsonRpcMessage = new JsonRpcError
|
||||
{
|
||||
Id = response.Id,
|
||||
Error = new JsonRpcErrorDetail
|
||||
{
|
||||
Code = (int)McpErrorCode.InvalidRequest,
|
||||
Message = $"Too many pending MRTR flows for this session (limit: {_maxFlowsPerSession}).",
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await next(context, cancellationToken);
|
||||
});
|
||||
|
||||
// Incoming filter: detect retries (requests with requestState) and decrement.
|
||||
options.Filters.Message.IncomingFilters.Add(next => async (context, cancellationToken) =>
|
||||
{
|
||||
if (context.JsonRpcMessage is JsonRpcRequest request &&
|
||||
request.Params is JsonObject paramsObj &&
|
||||
paramsObj.TryGetPropertyValue("requestState", out var stateNode) &&
|
||||
stateNode is not null)
|
||||
{
|
||||
var sessionId = context.Server.SessionId ?? "unknown";
|
||||
_pendingFlowsPerSession.AddOrUpdate(sessionId, 0, (_, c) => Math.Max(0, c - 1));
|
||||
}
|
||||
|
||||
await next(context, cancellationToken);
|
||||
});
|
||||
});
|
||||
|
||||
mcpServerBuilder.WithTools([
|
||||
McpServerTool.Create(
|
||||
async (string message, McpServer server, CancellationToken ct) =>
|
||||
{
|
||||
var result = await server.ElicitAsync(new ElicitRequestParams
|
||||
{
|
||||
Message = message,
|
||||
RequestedSchema = new()
|
||||
}, ct);
|
||||
|
||||
return $"{result.Action}";
|
||||
},
|
||||
new McpServerToolCreateOptions
|
||||
{
|
||||
Name = "elicit-tool",
|
||||
Description = "A tool that requests elicitation"
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OutgoingFilter_TracksIncompleteResultsPerSession()
|
||||
{
|
||||
// Verify that an outgoing message filter can observe InputRequiredResult responses
|
||||
// and track the pending MRTR flow count per session using context.Server.SessionId.
|
||||
StartServer();
|
||||
var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" };
|
||||
clientOptions.Handlers.ElicitationHandler = (request, ct) =>
|
||||
new ValueTask<ElicitResult>(new ElicitResult { Action = "accept" });
|
||||
|
||||
await using var client = await CreateMcpClientForServer(clientOptions);
|
||||
|
||||
// Call the tool - triggers one MRTR round-trip.
|
||||
var result = await client.CallToolAsync("elicit-tool",
|
||||
new Dictionary<string, object?> { ["message"] = "confirm?" },
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal("accept", Assert.IsType<TextContentBlock>(Assert.Single(result.Content)).Text);
|
||||
|
||||
// Verify the filter observed exactly one InputRequiredResult and tracked it.
|
||||
Assert.Single(_observations);
|
||||
var (sessionId, pendingCount) = _observations.First();
|
||||
Assert.NotNull(sessionId);
|
||||
Assert.Equal(1, pendingCount);
|
||||
|
||||
// After the retry completed, the count should be back to 0.
|
||||
Assert.Equal(0, _pendingFlowsPerSession.GetValueOrDefault(sessionId));
|
||||
|
||||
_messageTracker.AssertMrtrUsed();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OutgoingFilter_CanEnforcePerSessionMrtrLimit()
|
||||
{
|
||||
// Verify that an outgoing message filter can enforce a per-session MRTR flow limit
|
||||
// by replacing the InputRequiredResult with a JSON-RPC error when the limit is exceeded.
|
||||
// Set the limit to 0 so the very first MRTR flow is blocked.
|
||||
_maxFlowsPerSession = 0;
|
||||
|
||||
StartServer();
|
||||
var clientOptions = new McpClientOptions { ProtocolVersion = "DRAFT-2026-v1" };
|
||||
clientOptions.Handlers.ElicitationHandler = (request, ct) =>
|
||||
new ValueTask<ElicitResult>(new ElicitResult { Action = "accept" });
|
||||
|
||||
await using var client = await CreateMcpClientForServer(clientOptions);
|
||||
|
||||
// The tool call should fail because the outgoing filter blocks the InputRequiredResult.
|
||||
var ex = await Assert.ThrowsAsync<McpProtocolException>(async () =>
|
||||
await client.CallToolAsync("elicit-tool",
|
||||
new Dictionary<string, object?> { ["message"] = "confirm?" },
|
||||
cancellationToken: TestContext.Current.CancellationToken));
|
||||
|
||||
Assert.Contains("Too many pending MRTR flows", ex.Message);
|
||||
Assert.Equal(1, _blockedFlowCount);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue