From 92e68aa35a8608d1cb83dbbbfb94f6e7a195777c Mon Sep 17 00:00:00 2001 From: Connor Moss Date: Fri, 4 Sep 2026 09:21:51 -0400 Subject: [PATCH] Let the typed elicitation overload reach the interceptor ElicitAsync called ThrowIfElicitationUnsupported before delegating to the untyped overload, which skips that check when an outgoing-request interceptor is installed because the interceptor delivers the request instead. The typed overload therefore threw where the untyped one succeeded, so a server eliciting from a background task could use one API and not the other. Leave the check to the untyped overload, which already runs it on the path that needs it. SampleAsync and RequestRootsAsync already order it this way; this was the only outlier. Add the first test to exercise WithOutgoingRequestInterceptor at all. --- .../Server/McpServer.Methods.cs | 6 ++- .../Server/McpServerTests.cs | 46 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs index a9a5dddfb..c857ec118 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs @@ -411,8 +411,10 @@ public async ValueTask> ElicitAsync( Meta = options?.GetMetaForRequest(), }; - ThrowIfElicitationUnsupported(request); - + // The untyped overload runs the capability check itself, and skips it when an + // outgoing-request interceptor is installed because that channel delivers the + // request instead. Checking here would make this overload throw where the + // untyped one succeeds. var raw = await ElicitAsync(request, cancellationToken).ConfigureAwait(false); if (!raw.IsAccepted || raw.Content is null) diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index e54f40dcb..0370a7b81 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -258,6 +258,52 @@ public async Task ElicitAsync_Should_SendRequest() await runTask; } + private sealed class ElicitationTarget + { + public string Answer { get; set; } = string.Empty; + } + + [Fact] + public async Task ElicitAsync_Generic_Should_Use_Interceptor_When_Client_Does_Not_Support_Elicitation() + { + // An installed interceptor delivers the request over its own channel, so the + // untyped overload skips the capability check on purpose. The generic overload + // has to reach that branch rather than throwing ahead of it. + await using var transport = new TestServerTransport(); + await using var server = McpServer.Create(transport, _options, LoggerFactory); + var runTask = server.RunAsync(TestContext.Current.CancellationToken); + await InitializeServerAsync(transport, new ClientCapabilities(), TestContext.Current.CancellationToken); + + var intercepted = false; +#pragma warning disable MCPEXP002 // exercises the experimental outgoing-request interception seam + var interceptingServer = server.WithOutgoingRequestInterceptor((method, _, _) => + { + intercepted = true; + Assert.Equal(RequestMethods.ElicitationCreate, method); + return new ValueTask(JsonSerializer.SerializeToNode( + new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + ["answer"] = JsonSerializer.SerializeToElement("yes"), + }, + }, + McpJsonUtilities.DefaultOptions)); + }); +#pragma warning restore MCPEXP002 + + var result = await interceptingServer.ElicitAsync( + "Proceed?", cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(intercepted); + Assert.True(result.IsAccepted); + Assert.Equal("yes", result.Content?.Answer); + + await transport.DisposeAsync(); + await runTask; + } + [Fact] public async Task Can_Handle_Ping_Requests() {