Add public subscriptions/listen server handler (SEP-2575) (#1775)
Co-authored-by: Tarek Mahmoud Sayed <tarekms@ntdev.microsoft.com> Copilot-Session: 80653343-5dcb-43cb-89b0-8ac8e572c4f7
This commit is contained in:
parent
79e13b3e2c
commit
81ae6ec6c7
3 changed files with 689 additions and 13 deletions
|
|
@ -380,6 +380,196 @@ public class StatelessServerTests(ITestOutputHelper outputHelper) : KestrelInMem
|
|||
Assert.Null(grantedNotifications["resourceSubscriptions"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscriptionsListen_WithCustomHandler_InStatelessMode_StreamsNotificationOverHeldOpenPost()
|
||||
{
|
||||
// The built-in stateless handler grants nothing and returns immediately because there is no
|
||||
// session-wide channel. A custom SubscriptionsListenHandler can instead stream notifications over the
|
||||
// held-open POST response (the listen request's RelatedTransport), which is the solicited
|
||||
// server-to-client stream. This is the core scenario of issue #1662.
|
||||
const string subscribedUri = "resource://test";
|
||||
|
||||
Builder.Services.AddMcpServer()
|
||||
.WithHttpTransport(options =>
|
||||
{
|
||||
options.Stateless = true;
|
||||
})
|
||||
.WithSubscriptionsListenHandler(async (request, cancellationToken) =>
|
||||
{
|
||||
var subscriptionId = request.JsonRpcRequest.Id;
|
||||
|
||||
var ack = new JsonRpcNotification
|
||||
{
|
||||
Method = NotificationMethods.SubscriptionsAcknowledgedNotification,
|
||||
Params = JsonSerializer.SerializeToNode(
|
||||
new SubscriptionsAcknowledgedNotificationParams
|
||||
{
|
||||
Notifications = new SubscriptionsListenNotifications
|
||||
{
|
||||
ResourceSubscriptions = request.Params.Notifications.ResourceSubscriptions,
|
||||
},
|
||||
},
|
||||
McpJsonUtilities.DefaultOptions),
|
||||
};
|
||||
TagWithSubscriptionId(ack, subscriptionId);
|
||||
await request.Server.SendMessageAsync(ack, cancellationToken);
|
||||
|
||||
var updated = new JsonRpcNotification
|
||||
{
|
||||
Method = NotificationMethods.ResourceUpdatedNotification,
|
||||
Params = new JsonObject { ["uri"] = subscribedUri },
|
||||
};
|
||||
TagWithSubscriptionId(updated, subscriptionId);
|
||||
await request.Server.SendMessageAsync(updated, cancellationToken);
|
||||
|
||||
// Complete the stream so the POST response finishes; the buffered notifications flush to the client.
|
||||
return new EmptyResult();
|
||||
});
|
||||
|
||||
_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"));
|
||||
|
||||
await using var client = await ConnectMcpClientAsync();
|
||||
|
||||
var ackChannel = Channel.CreateUnbounded<JsonRpcNotification>();
|
||||
var updatedChannel = Channel.CreateUnbounded<JsonRpcNotification>();
|
||||
await using var ackReg = client.RegisterNotificationHandler(NotificationMethods.SubscriptionsAcknowledgedNotification,
|
||||
(notification, _) => { ackChannel.Writer.TryWrite(notification); return default; });
|
||||
await using var updatedReg = client.RegisterNotificationHandler(NotificationMethods.ResourceUpdatedNotification,
|
||||
(notification, _) => { updatedChannel.Writer.TryWrite(notification); return default; });
|
||||
|
||||
var listenRequest = new JsonRpcRequest
|
||||
{
|
||||
Method = RequestMethods.SubscriptionsListen,
|
||||
Params = JsonSerializer.SerializeToNode(
|
||||
new SubscriptionsListenRequestParams
|
||||
{
|
||||
Notifications = new SubscriptionsListenNotifications { ResourceSubscriptions = [subscribedUri] },
|
||||
},
|
||||
McpJsonUtilities.DefaultOptions),
|
||||
};
|
||||
|
||||
await client.SendRequestAsync(listenRequest, TestContext.Current.CancellationToken)
|
||||
.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
|
||||
|
||||
var ack = await ackChannel.Reader.ReadAsync(TestContext.Current.CancellationToken);
|
||||
var subscriptionId = GetSubscriptionId(ack);
|
||||
Assert.NotNull(subscriptionId);
|
||||
|
||||
var updated = await updatedChannel.Reader.ReadAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Equal(subscriptionId, GetSubscriptionId(updated));
|
||||
Assert.Equal(subscribedUri, Assert.IsType<JsonObject>(updated.Params)["uri"]?.GetValue<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscriptionsListen_WithCustomHandler_InStatelessMode_AdvertisesAndStreamsListChanged()
|
||||
{
|
||||
// resources/updated rides on resources.subscribe, which is never suppressed, so it cannot prove the
|
||||
// listChanged capability is advertised. A custom SubscriptionsListenHandler gives a stateless server a
|
||||
// way to deliver */list_changed over the held-open POST, so server/discover (the only path a
|
||||
// 2026-07-28+ client uses) must advertise tools.listChanged rather than dropping it (issue #1662).
|
||||
Builder.Services.AddMcpServer()
|
||||
.WithHttpTransport(options =>
|
||||
{
|
||||
options.Stateless = true;
|
||||
})
|
||||
.WithTools([McpServerTool.Create(() => "result", new() { Name = "myTool" })])
|
||||
.WithSubscriptionsListenHandler(async (request, cancellationToken) =>
|
||||
{
|
||||
var subscriptionId = request.JsonRpcRequest.Id;
|
||||
|
||||
var ack = new JsonRpcNotification
|
||||
{
|
||||
Method = NotificationMethods.SubscriptionsAcknowledgedNotification,
|
||||
Params = JsonSerializer.SerializeToNode(
|
||||
new SubscriptionsAcknowledgedNotificationParams
|
||||
{
|
||||
Notifications = new SubscriptionsListenNotifications
|
||||
{
|
||||
ToolsListChanged = request.Params.Notifications.ToolsListChanged,
|
||||
},
|
||||
},
|
||||
McpJsonUtilities.DefaultOptions),
|
||||
};
|
||||
TagWithSubscriptionId(ack, subscriptionId);
|
||||
await request.Server.SendMessageAsync(ack, cancellationToken);
|
||||
|
||||
var listChanged = new JsonRpcNotification { Method = NotificationMethods.ToolListChangedNotification };
|
||||
TagWithSubscriptionId(listChanged, subscriptionId);
|
||||
await request.Server.SendMessageAsync(listChanged, cancellationToken);
|
||||
|
||||
return new EmptyResult();
|
||||
});
|
||||
|
||||
// Advertise tools.listChanged so the per-response capability decision has something to preserve.
|
||||
Builder.Services.Configure<McpServerOptions>(options =>
|
||||
{
|
||||
options.Capabilities ??= new();
|
||||
options.Capabilities.Tools ??= new();
|
||||
options.Capabilities.Tools.ListChanged = true;
|
||||
});
|
||||
|
||||
_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"));
|
||||
|
||||
await using var client = await ConnectMcpClientAsync();
|
||||
|
||||
// The stateless server can now deliver tools/list_changed over the custom listen stream, so the
|
||||
// capability must survive on the server/discover response instead of being cleared.
|
||||
Assert.True(client.ServerCapabilities.Tools?.ListChanged);
|
||||
|
||||
var listChangedChannel = Channel.CreateUnbounded<JsonRpcNotification>();
|
||||
await using var listChangedReg = client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification,
|
||||
(notification, _) => { listChangedChannel.Writer.TryWrite(notification); return default; });
|
||||
|
||||
var listenRequest = new JsonRpcRequest
|
||||
{
|
||||
Method = RequestMethods.SubscriptionsListen,
|
||||
Params = JsonSerializer.SerializeToNode(
|
||||
new SubscriptionsListenRequestParams
|
||||
{
|
||||
Notifications = new SubscriptionsListenNotifications { ToolsListChanged = true },
|
||||
},
|
||||
McpJsonUtilities.DefaultOptions),
|
||||
};
|
||||
|
||||
await client.SendRequestAsync(listenRequest, TestContext.Current.CancellationToken)
|
||||
.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
|
||||
|
||||
var listChangedNotification = await listChangedChannel.Reader.ReadAsync(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(GetSubscriptionId(listChangedNotification));
|
||||
}
|
||||
|
||||
private static string? GetSubscriptionId(JsonRpcNotification notification)
|
||||
=> ((notification.Params as JsonObject)?["_meta"] as JsonObject)?[MetaKeys.SubscriptionId]?.ToJsonString();
|
||||
|
||||
private static void TagWithSubscriptionId(JsonRpcNotification notification, RequestId subscriptionId)
|
||||
{
|
||||
var paramsObject = notification.Params as JsonObject ?? new JsonObject();
|
||||
if (paramsObject["_meta"] is not JsonObject meta)
|
||||
{
|
||||
meta = new JsonObject();
|
||||
paramsObject["_meta"] = meta;
|
||||
}
|
||||
|
||||
meta[MetaKeys.SubscriptionId] = subscriptionId.Id switch
|
||||
{
|
||||
string stringId => JsonValue.Create(stringId),
|
||||
long longId => JsonValue.Create(longId),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
notification.Params = paramsObject;
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "testSamplingErrors")]
|
||||
public static async Task<string> TestSamplingErrors(McpServer server)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -147,4 +147,17 @@ public class McpServerBuilderExtensionsHandlerTests
|
|||
|
||||
Assert.Equal(handler, options.Handlers.UnsubscribeFromResourcesHandler);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithSubscriptionsListenHandler_Sets_Handler()
|
||||
{
|
||||
McpRequestHandler<SubscriptionsListenRequestParams, EmptyResult> handler = async (context, token) => new EmptyResult();
|
||||
|
||||
_builder.Object.WithSubscriptionsListenHandler(handler);
|
||||
|
||||
var serviceProvider = _services.BuildServiceProvider();
|
||||
var options = serviceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
|
||||
|
||||
Assert.Equal(handler, options.Handlers.SubscriptionsListenHandler);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,278 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using ModelContextProtocol.Tests.Utils;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace ModelContextProtocol.Tests.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the custom <see cref="McpServerHandlers.SubscriptionsListenHandler"/> (SEP-2575, issue #1662).
|
||||
/// A custom handler is a full replacement for the built-in <c>subscriptions/listen</c> handling: it owns the
|
||||
/// stream, sends the acknowledgement itself, streams application-defined notifications, and receives no
|
||||
/// automatic <c>*/list_changed</c> fan-out. These tests run over the in-memory stream transport exercised by
|
||||
/// <see cref="ClientServerTestBase"/>.
|
||||
/// </summary>
|
||||
public class SubscriptionsListenHandlerTests : ClientServerTestBase
|
||||
{
|
||||
private const string CustomResourceUri = "custom://event/1";
|
||||
|
||||
// Signalled after the custom handler has sent its acknowledgement and first notification and is waiting
|
||||
// for cancellation. Lets cancellation tests wait until the handler is actually holding the stream open.
|
||||
private readonly TaskCompletionSource<bool> _handlerHoldingStream = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
// Signalled from the custom handler's finally block, proving it observed cancellation and cleaned up.
|
||||
private readonly TaskCompletionSource<bool> _handlerCleanedUp = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public SubscriptionsListenHandlerTests(ITestOutputHelper testOutputHelper)
|
||||
: base(testOutputHelper)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
|
||||
{
|
||||
// Register a tool so the server advertises tools.listChanged; this lets the replacement test below
|
||||
// trigger a collection change and prove the built-in fan-out no longer runs for the custom handler.
|
||||
mcpServerBuilder.WithTools<ListenTools>();
|
||||
|
||||
mcpServerBuilder.WithSubscriptionsListenHandler(async (request, cancellationToken) =>
|
||||
{
|
||||
var subscriptionId = request.JsonRpcRequest.Id;
|
||||
|
||||
// SEP-2575 requires the acknowledgement to be the first message on the stream. The custom handler
|
||||
// owns this: it echoes back the requested filters as granted and tags the ack with the id.
|
||||
var ack = new JsonRpcNotification
|
||||
{
|
||||
Method = NotificationMethods.SubscriptionsAcknowledgedNotification,
|
||||
Params = JsonSerializer.SerializeToNode(
|
||||
new SubscriptionsAcknowledgedNotificationParams { Notifications = request.Params.Notifications },
|
||||
McpJsonUtilities.DefaultOptions),
|
||||
};
|
||||
TagWithSubscriptionId(ack, subscriptionId);
|
||||
await request.Server.SendMessageAsync(ack, cancellationToken);
|
||||
|
||||
// Stream one application-defined notification the built-in handler could never produce on its own,
|
||||
// proving the handler drives the stream. Tagged with the same subscription id.
|
||||
var updated = new JsonRpcNotification
|
||||
{
|
||||
Method = NotificationMethods.ResourceUpdatedNotification,
|
||||
Params = new JsonObject { ["uri"] = CustomResourceUri },
|
||||
};
|
||||
TagWithSubscriptionId(updated, subscriptionId);
|
||||
await request.Server.SendMessageAsync(updated, cancellationToken);
|
||||
|
||||
_handlerHoldingStream.TrySetResult(true);
|
||||
|
||||
try
|
||||
{
|
||||
// Remain active for the subscription lifetime, exactly like the built-in handler, until the
|
||||
// request-scoped token is cancelled (notifications/cancelled on stdio, client disconnect on HTTP).
|
||||
var cancelled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using var registration = cancellationToken.Register(
|
||||
static state => ((TaskCompletionSource<bool>)state!).TrySetResult(true), cancelled);
|
||||
await cancelled.Task;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_handlerCleanedUp.TrySetResult(true);
|
||||
}
|
||||
|
||||
return new EmptyResult();
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CustomHandler_July2026_SendsAcknowledgementThenTaggedNotification()
|
||||
{
|
||||
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion,
|
||||
});
|
||||
|
||||
// Capture the acknowledgement and the streamed notification on separate channels. Both must arrive
|
||||
// tagged with the subscription id. The test does not assert cross-notification arrival order because
|
||||
// the client dispatches incoming messages concurrently (see McpSessionHandler.ProcessMessagesCoreAsync),
|
||||
// so handler invocation order is not observable; acknowledgement-first is a server-side/wire guarantee.
|
||||
var ackChannel = Channel.CreateUnbounded<JsonRpcNotification>();
|
||||
var updatedChannel = Channel.CreateUnbounded<JsonRpcNotification>();
|
||||
|
||||
await using var ackReg = client.RegisterNotificationHandler(NotificationMethods.SubscriptionsAcknowledgedNotification,
|
||||
(notification, _) => { ackChannel.Writer.TryWrite(notification); return default; });
|
||||
await using var updatedReg = client.RegisterNotificationHandler(NotificationMethods.ResourceUpdatedNotification,
|
||||
(notification, _) => { updatedChannel.Writer.TryWrite(notification); return default; });
|
||||
|
||||
using var listenCts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
||||
var listenTask = SendSubscriptionsListenAsync(
|
||||
client, new SubscriptionsListenNotifications { ResourcesListChanged = true }, listenCts.Token);
|
||||
|
||||
// The acknowledgement carries the subscription id.
|
||||
var ack = await ackChannel.Reader.ReadAsync(TestContext.Current.CancellationToken);
|
||||
var subscriptionId = GetSubscriptionId(ack);
|
||||
Assert.NotNull(subscriptionId);
|
||||
|
||||
// The custom application notification is tagged with the same subscription id.
|
||||
var updated = await updatedChannel.Reader.ReadAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Equal(subscriptionId, GetSubscriptionId(updated));
|
||||
Assert.Equal(CustomResourceUri, (updated.Params as JsonObject)?["uri"]?.GetValue<string>());
|
||||
|
||||
await CancelSubscriptionAsync(listenCts, listenTask);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CustomHandler_ReplacesBuiltIn_SuppressesAutomaticListChangedFanOut()
|
||||
{
|
||||
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion,
|
||||
});
|
||||
|
||||
var ackChannel = Channel.CreateUnbounded<JsonRpcNotification>();
|
||||
var toolsChannel = Channel.CreateUnbounded<JsonRpcNotification>();
|
||||
|
||||
await using var ackReg = client.RegisterNotificationHandler(NotificationMethods.SubscriptionsAcknowledgedNotification,
|
||||
(notification, _) => { ackChannel.Writer.TryWrite(notification); return default; });
|
||||
await using var toolsReg = client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification,
|
||||
(notification, _) => { toolsChannel.Writer.TryWrite(notification); return default; });
|
||||
|
||||
using var listenCts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
||||
// Request tools/list_changed. The built-in handler would fan these out; the custom handler replaces it
|
||||
// and never tracks the subscription, so the SDK must deliver nothing automatically for this change.
|
||||
var listenTask = SendSubscriptionsListenAsync(
|
||||
client, new SubscriptionsListenNotifications { ToolsListChanged = true }, listenCts.Token);
|
||||
|
||||
// Wait until the custom handler is holding the stream open (ack + first notification already sent).
|
||||
await _handlerHoldingStream.Task.WaitAsync(TestContext.Current.CancellationToken);
|
||||
await ackChannel.Reader.ReadAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Mutate the tool collection. With the built-in handler this would deliver a tagged tools/list_changed;
|
||||
// under the custom replacement handler it must not, because _activeSubscriptions is never populated.
|
||||
// The list-changed fan-out iterates that (empty) set and completes synchronously during Add, so it
|
||||
// buffers nothing to send. The ListToolsAsync round-trip then flushes the client read pipeline.
|
||||
var serverOptions = ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
|
||||
serverOptions.ToolCollection!.Add(McpServerTool.Create([McpServerTool(Name = "AddedTool")] () => "42"));
|
||||
|
||||
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
await CancelSubscriptionAsync(listenCts, listenTask);
|
||||
|
||||
// Completed-and-empty proves nothing was ever delivered, not merely "nothing buffered right now":
|
||||
// WaitToReadAsync returns false only when the channel is both empty and completed.
|
||||
toolsChannel.Writer.Complete();
|
||||
Assert.False(await toolsChannel.Reader.WaitToReadAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CustomHandler_PreJuly2026_IsRejectedWithMethodNotFound()
|
||||
{
|
||||
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion,
|
||||
});
|
||||
|
||||
var request = new JsonRpcRequest
|
||||
{
|
||||
Method = RequestMethods.SubscriptionsListen,
|
||||
Params = JsonSerializer.SerializeToNode(
|
||||
new SubscriptionsListenRequestParams { Notifications = new SubscriptionsListenNotifications { ResourcesListChanged = true } },
|
||||
McpJsonUtilities.DefaultOptions),
|
||||
};
|
||||
|
||||
var ex = await Assert.ThrowsAsync<McpProtocolException>(() =>
|
||||
client.SendRequestAsync(request, TestContext.Current.CancellationToken));
|
||||
|
||||
Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CustomHandler_OnCancellation_ObservesTokenAndCleansUp()
|
||||
{
|
||||
await using McpClient client = await CreateMcpClientForServer(new McpClientOptions
|
||||
{
|
||||
ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion,
|
||||
});
|
||||
|
||||
// Use an explicit request id so the test can address it in a notifications/cancelled message,
|
||||
// which is the transport-level cancellation signal for a long-lived subscriptions/listen on stdio
|
||||
// (an HTTP client would instead disconnect, which cancels the same request-scoped token).
|
||||
var subscriptionId = new RequestId("listen-cancel-1");
|
||||
using var listenCts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
|
||||
var listenTask = SendSubscriptionsListenAsync(
|
||||
client, new SubscriptionsListenNotifications { ResourcesListChanged = true }, listenCts.Token, subscriptionId);
|
||||
|
||||
// Ensure the handler is actively holding the stream before cancelling.
|
||||
await _handlerHoldingStream.Task.WaitAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Deterministically drive server-side cancellation by sending notifications/cancelled for the request
|
||||
// id. The server routes this to the request-scoped token the handler is observing.
|
||||
await client.SendMessageAsync(new JsonRpcNotification
|
||||
{
|
||||
Method = NotificationMethods.CancelledNotification,
|
||||
Params = JsonSerializer.SerializeToNode(
|
||||
new CancelledNotificationParams { RequestId = subscriptionId },
|
||||
McpJsonUtilities.DefaultOptions),
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
// The handler observed the token: its cancellation registration ran and its finally block completed.
|
||||
await _handlerCleanedUp.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
|
||||
|
||||
// A cancelled request sends no response, so unblock the client side and confirm it observes cancellation.
|
||||
await CancelSubscriptionAsync(listenCts, listenTask);
|
||||
}
|
||||
|
||||
private static Task SendSubscriptionsListenAsync(
|
||||
McpClient client, SubscriptionsListenNotifications notifications, CancellationToken cancellationToken, RequestId requestId = default)
|
||||
{
|
||||
var request = new JsonRpcRequest
|
||||
{
|
||||
Method = RequestMethods.SubscriptionsListen,
|
||||
Params = JsonSerializer.SerializeToNode(
|
||||
new SubscriptionsListenRequestParams { Notifications = notifications },
|
||||
McpJsonUtilities.DefaultOptions),
|
||||
};
|
||||
|
||||
if (requestId.Id is not null)
|
||||
{
|
||||
request.Id = requestId;
|
||||
}
|
||||
|
||||
return client.SendRequestAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task CancelSubscriptionAsync(CancellationTokenSource listenCts, Task listenTask)
|
||||
{
|
||||
await listenCts.CancelAsync();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => listenTask);
|
||||
}
|
||||
|
||||
private static string? GetSubscriptionId(JsonRpcNotification notification)
|
||||
=> ((notification.Params as JsonObject)?["_meta"] as JsonObject)?[MetaKeys.SubscriptionId]?.ToJsonString();
|
||||
|
||||
private static void TagWithSubscriptionId(JsonRpcNotification notification, RequestId subscriptionId)
|
||||
{
|
||||
var paramsObject = notification.Params as JsonObject ?? new JsonObject();
|
||||
if (paramsObject["_meta"] is not JsonObject meta)
|
||||
{
|
||||
meta = new JsonObject();
|
||||
paramsObject["_meta"] = meta;
|
||||
}
|
||||
|
||||
meta[MetaKeys.SubscriptionId] = subscriptionId.Id switch
|
||||
{
|
||||
string stringId => JsonValue.Create(stringId),
|
||||
long longId => JsonValue.Create(longId),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
notification.Params = paramsObject;
|
||||
}
|
||||
|
||||
private sealed class ListenTools
|
||||
{
|
||||
[McpServerTool, System.ComponentModel.Description("Echoes the input back to the caller.")]
|
||||
public static string Echo([System.ComponentModel.Description("The message to echo.")] string message) => message;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue