Skip to content

Commit 4552822

Browse files
halter73Copilot
andcommitted
Make the interceptor's delivery capability explicit
An outgoing-request interceptor replaces the session's server-to-client channel, so it can deliver sampling, roots, and elicitation requests even when the underlying transport cannot. That is what lets background task execution park a request in an IMcpTaskStore and have the client answer it on a later, unrelated request, which does not depend on session affinity. OutgoingRequestInterceptingMcpServer relied on inheriting the base SupportsServerToClientRequests default to get this, while forwarding every other member to the wrapped server. Make the override explicit so the behavior is stated rather than implied, and add stateless coverage for AsSamplingChatClient() and the generic ElicitAsync<T>(), which are the two APIs that check the capability guard eagerly instead of routing through the interceptor short-circuit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 606765f commit 4552822

2 files changed

Lines changed: 59 additions & 0 deletions

File tree

‎src/ModelContextProtocol.Core/Server/OutgoingRequestInterceptingMcpServer.cs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ internal sealed class OutgoingRequestInterceptingMcpServer(
1717

1818
public override ClientCapabilities? ClientCapabilities => server.ClientCapabilities;
1919

20+
// The interceptor replaces the session's server-to-client channel, so it can deliver requests even
21+
// when the underlying transport cannot (for example a stateless HTTP request, where a background task
22+
// parks the request in an IMcpTaskStore and the client answers it on a later, unrelated request).
23+
internal override bool SupportsServerToClientRequests => true;
24+
2025
public override Implementation? ClientInfo => server.ClientInfo;
2126

2227
public override McpServerOptions ServerOptions => server.ServerOptions;

‎tests/ModelContextProtocol.AspNetCore.Tests/StatelessServerTests.cs‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Microsoft.AspNetCore.Builder;
2+
using Microsoft.Extensions.AI;
23
using Microsoft.Extensions.DependencyInjection;
34
using ModelContextProtocol.AspNetCore.Tests.Utils;
45
using ModelContextProtocol.Client;
@@ -9,6 +10,7 @@
910
using System.Net;
1011
using System.Text.Json;
1112
using System.Text.Json.Nodes;
13+
using System.Text.Json.Serialization;
1214
using System.Threading.Channels;
1315

1416
namespace ModelContextProtocol.AspNetCore.Tests;
@@ -173,6 +175,24 @@ public async Task ClientCapabilities_AreAvailableFromInjectedServer()
173175
Assert.IsType<TextContentBlock>(Assert.Single(toolResponse.Content)).Text);
174176
}
175177

178+
[Fact]
179+
public async Task InterceptedServerToClientRequests_Succeed_InStatelessMode()
180+
{
181+
await StartAsync();
182+
var clientOptions = new McpClientOptions();
183+
clientOptions.Handlers.SamplingHandler = (_, _, _) => throw new UnreachableException();
184+
clientOptions.Handlers.ElicitationHandler = (_, _) => throw new UnreachableException();
185+
await using var client = await ConnectMcpClientAsync(clientOptions);
186+
187+
var toolResponse = await client.CallToolAsync(
188+
"testInterceptedRequests",
189+
cancellationToken: TestContext.Current.CancellationToken);
190+
191+
Assert.Equal(
192+
"intercepted-sample|Seattle",
193+
Assert.IsType<TextContentBlock>(Assert.Single(toolResponse.Content)).Text);
194+
}
195+
176196
[Fact]
177197
public async Task UnsolicitedNotification_Fails_WithInvalidOperationException()
178198
{
@@ -654,6 +674,31 @@ public static string GetClientCapabilities(McpServer server) =>
654674
server.ClientCapabilities?.Roots is null ? "no-roots" : "roots",
655675
server.ClientCapabilities?.Elicitation is null ? "no-elicitation" : "elicitation");
656676

677+
[McpServerTool(Name = "testInterceptedRequests")]
678+
public static async Task<string> TestInterceptedRequests(McpServer server)
679+
{
680+
// Background task execution replaces the server-to-client channel with an interceptor that parks the
681+
// request in an IMcpTaskStore, so the client can answer it on a later, unrelated request. That does not
682+
// depend on session affinity, which is why these requests are allowed through in stateless mode.
683+
#pragma warning disable MCPEXP002
684+
var intercepted = server.WithOutgoingRequestInterceptor((method, _, _) => new(JsonNode.Parse(method switch
685+
{
686+
RequestMethods.SamplingCreateMessage =>
687+
"""{"role":"assistant","content":{"type":"text","text":"intercepted-sample"},"model":"test-model"}""",
688+
RequestMethods.ElicitationCreate =>
689+
"""{"action":"accept","content":{"city":"Seattle"}}""",
690+
_ => throw new UnreachableException(),
691+
})));
692+
#pragma warning restore MCPEXP002
693+
694+
var samplingResponse = await intercepted.AsSamplingChatClient().GetResponseAsync("Where am I?");
695+
var elicitResult = await intercepted.ElicitAsync<CityForm>(
696+
"Which city?",
697+
options: new() { JsonSerializerOptions = StatelessInterceptorJsonContext.Default.Options });
698+
699+
return $"{samplingResponse.Text}|{elicitResult.Content?.City}";
700+
}
701+
657702
[McpServerTool(Name = "testScope")]
658703
public static string? TestScope(ScopedService scopedService) => scopedService.State;
659704

@@ -667,3 +712,12 @@ private class SynchronousProgress<T>(Action<T> handler) : IProgress<T>
667712
public void Report(T value) => handler(value);
668713
}
669714
}
715+
716+
public class CityForm
717+
{
718+
public string? City { get; set; }
719+
}
720+
721+
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
722+
[JsonSerializable(typeof(CityForm))]
723+
internal partial class StatelessInterceptorJsonContext : JsonSerializerContext;

0 commit comments

Comments
 (0)