Implement cancellation notifications (#146)
* Implement cancellation notifications * Address PR feedback * Address feedback
This commit is contained in:
parent
5d3fb65b11
commit
8fe96bf6ba
5 changed files with 201 additions and 43 deletions
|
|
@ -68,6 +68,9 @@ internal static partial class Log
|
||||||
[LoggerMessage(Level = LogLevel.Error, Message = "Request failed for {endpointName} with method {method}: {message} ({code})")]
|
[LoggerMessage(Level = LogLevel.Error, Message = "Request failed for {endpointName} with method {method}: {message} ({code})")]
|
||||||
internal static partial void RequestFailed(this ILogger logger, string endpointName, string method, string message, int code);
|
internal static partial void RequestFailed(this ILogger logger, string endpointName, string method, string message, int code);
|
||||||
|
|
||||||
|
[LoggerMessage(Level = LogLevel.Information, Message = "Request '{requestId}' canceled via client notification with reason '{Reason}'.")]
|
||||||
|
internal static partial void RequestCanceled(this ILogger logger, RequestId requestId, string? reason);
|
||||||
|
|
||||||
[LoggerMessage(Level = LogLevel.Information, Message = "Request response received payload for {endpointName}: {payload}")]
|
[LoggerMessage(Level = LogLevel.Information, Message = "Request response received payload for {endpointName}: {payload}")]
|
||||||
internal static partial void RequestResponseReceivedPayload(this ILogger logger, string endpointName, string payload);
|
internal static partial void RequestResponseReceivedPayload(this ILogger logger, string endpointName, string payload);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace ModelContextProtocol.Protocol.Messages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This notification indicates that the result will be unused, so any associated processing SHOULD cease.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CancelledNotification
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The ID of the request to cancel.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("requestId")]
|
||||||
|
public RequestId RequestId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An optional string describing the reason for the cancellation.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("reason")]
|
||||||
|
public string? Reason { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -20,7 +20,13 @@ internal sealed class McpSession : IDisposable
|
||||||
private readonly RequestHandlers _requestHandlers;
|
private readonly RequestHandlers _requestHandlers;
|
||||||
private readonly NotificationHandlers _notificationHandlers;
|
private readonly NotificationHandlers _notificationHandlers;
|
||||||
|
|
||||||
|
/// <summary>Collection of requests sent on this session and waiting for responses.</summary>
|
||||||
private readonly ConcurrentDictionary<RequestId, TaskCompletionSource<IJsonRpcMessage>> _pendingRequests = [];
|
private readonly ConcurrentDictionary<RequestId, TaskCompletionSource<IJsonRpcMessage>> _pendingRequests = [];
|
||||||
|
/// <summary>
|
||||||
|
/// Collection of requests received on this session and currently being handled. The value provides a <see cref="CancellationTokenSource"/>
|
||||||
|
/// that can be used to request cancellation of the in-flight handler.
|
||||||
|
/// </summary>
|
||||||
|
private readonly ConcurrentDictionary<RequestId, CancellationTokenSource> _handlingRequests = new();
|
||||||
private readonly JsonSerializerOptions _jsonOptions;
|
private readonly JsonSerializerOptions _jsonOptions;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
|
|
||||||
|
|
@ -69,25 +75,70 @@ internal sealed class McpSession : IDisposable
|
||||||
{
|
{
|
||||||
_logger.TransportMessageRead(EndpointName, message.GetType().Name);
|
_logger.TransportMessageRead(EndpointName, message.GetType().Name);
|
||||||
|
|
||||||
// Fire and forget the message handling task to avoid blocking the transport
|
|
||||||
// If awaiting the task, the transport will not be able to read more messages,
|
|
||||||
// which could lead to a deadlock if the handler sends a message back
|
|
||||||
_ = ProcessMessageAsync();
|
_ = ProcessMessageAsync();
|
||||||
async Task ProcessMessageAsync()
|
async Task ProcessMessageAsync()
|
||||||
{
|
{
|
||||||
#if NET
|
IJsonRpcMessageWithId? messageWithId = message as IJsonRpcMessageWithId;
|
||||||
await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
|
CancellationTokenSource? combinedCts = null;
|
||||||
#else
|
|
||||||
await default(ForceYielding);
|
|
||||||
#endif
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await HandleMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
// Register before we yield, so that the tracking is guaranteed to be there
|
||||||
|
// when subsequent messages arrive, even if the asynchronous processing happens
|
||||||
|
// out of order.
|
||||||
|
if (messageWithId is not null)
|
||||||
|
{
|
||||||
|
combinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
_handlingRequests[messageWithId.Id] = combinedCts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire and forget the message handling to avoid blocking the transport
|
||||||
|
// If awaiting the task, the transport will not be able to read more messages,
|
||||||
|
// which could lead to a deadlock if the handler sends a message back
|
||||||
|
|
||||||
|
#if NET
|
||||||
|
await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
|
||||||
|
#else
|
||||||
|
await default(ForceYielding);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Handle the message.
|
||||||
|
await HandleMessageAsync(message, combinedCts?.Token ?? cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
var payload = JsonSerializer.Serialize(message, _jsonOptions.GetTypeInfo<IJsonRpcMessage>());
|
// Only send responses for request errors that aren't user-initiated cancellation.
|
||||||
_logger.MessageHandlerError(EndpointName, message.GetType().Name, payload, ex);
|
bool isUserCancellation =
|
||||||
|
ex is OperationCanceledException &&
|
||||||
|
!cancellationToken.IsCancellationRequested &&
|
||||||
|
combinedCts?.IsCancellationRequested is true;
|
||||||
|
|
||||||
|
if (!isUserCancellation && message is JsonRpcRequest request)
|
||||||
|
{
|
||||||
|
_logger.RequestHandlerError(EndpointName, request.Method, ex);
|
||||||
|
await _transport.SendMessageAsync(new JsonRpcError
|
||||||
|
{
|
||||||
|
Id = request.Id,
|
||||||
|
JsonRpc = "2.0",
|
||||||
|
Error = new JsonRpcErrorDetail
|
||||||
|
{
|
||||||
|
Code = ErrorCodes.InternalError,
|
||||||
|
Message = ex.Message
|
||||||
|
}
|
||||||
|
}, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else if (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
var payload = JsonSerializer.Serialize(message, _jsonOptions.GetTypeInfo<IJsonRpcMessage>());
|
||||||
|
_logger.MessageHandlerError(EndpointName, message.GetType().Name, payload, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (messageWithId is not null)
|
||||||
|
{
|
||||||
|
_handlingRequests.TryRemove(messageWithId.Id, out _);
|
||||||
|
combinedCts!.Dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -123,6 +174,25 @@ internal sealed class McpSession : IDisposable
|
||||||
|
|
||||||
private async Task HandleNotification(JsonRpcNotification notification)
|
private async Task HandleNotification(JsonRpcNotification notification)
|
||||||
{
|
{
|
||||||
|
// Special-case cancellation to cancel a pending operation. (We'll still subsequently invoke a user-specified handler if one exists.)
|
||||||
|
if (notification.Method == NotificationMethods.CancelledNotification)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (GetCancelledNotificationParams(notification.Params) is CancelledNotification cn &&
|
||||||
|
_handlingRequests.TryGetValue(cn.RequestId, out var cts))
|
||||||
|
{
|
||||||
|
await cts.CancelAsync().ConfigureAwait(false);
|
||||||
|
_logger.RequestCanceled(cn.RequestId, cn.Reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// "Invalid cancellation notifications SHOULD be ignored"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle user-defined notifications.
|
||||||
if (_notificationHandlers.TryGetValue(notification.Method, out var handlers))
|
if (_notificationHandlers.TryGetValue(notification.Method, out var handlers))
|
||||||
{
|
{
|
||||||
foreach (var notificationHandler in handlers)
|
foreach (var notificationHandler in handlers)
|
||||||
|
|
@ -161,33 +231,15 @@ internal sealed class McpSession : IDisposable
|
||||||
{
|
{
|
||||||
if (_requestHandlers.TryGetValue(request.Method, out var handler))
|
if (_requestHandlers.TryGetValue(request.Method, out var handler))
|
||||||
{
|
{
|
||||||
try
|
_logger.RequestHandlerCalled(EndpointName, request.Method);
|
||||||
|
var result = await handler(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
_logger.RequestHandlerCompleted(EndpointName, request.Method);
|
||||||
|
await _transport.SendMessageAsync(new JsonRpcResponse
|
||||||
{
|
{
|
||||||
_logger.RequestHandlerCalled(EndpointName, request.Method);
|
Id = request.Id,
|
||||||
var result = await handler(request, cancellationToken).ConfigureAwait(false);
|
JsonRpc = "2.0",
|
||||||
_logger.RequestHandlerCompleted(EndpointName, request.Method);
|
Result = result
|
||||||
await _transport.SendMessageAsync(new JsonRpcResponse
|
}, cancellationToken).ConfigureAwait(false);
|
||||||
{
|
|
||||||
Id = request.Id,
|
|
||||||
JsonRpc = "2.0",
|
|
||||||
Result = result
|
|
||||||
}, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.RequestHandlerError(EndpointName, request.Method, ex);
|
|
||||||
// Send error response
|
|
||||||
await _transport.SendMessageAsync(new JsonRpcError
|
|
||||||
{
|
|
||||||
Id = request.Id,
|
|
||||||
JsonRpc = "2.0",
|
|
||||||
Error = new JsonRpcErrorDetail
|
|
||||||
{
|
|
||||||
Code = -32000, // Implementation defined error
|
|
||||||
Message = ex.Message
|
|
||||||
}
|
|
||||||
}, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
@ -273,7 +325,7 @@ internal sealed class McpSession : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default)
|
public async Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
Throw.IfNull(message);
|
Throw.IfNull(message);
|
||||||
|
|
||||||
|
|
@ -288,7 +340,44 @@ internal sealed class McpSession : IDisposable
|
||||||
_logger.SendingMessage(EndpointName, JsonSerializer.Serialize(message, _jsonOptions.GetTypeInfo<IJsonRpcMessage>()));
|
_logger.SendingMessage(EndpointName, JsonSerializer.Serialize(message, _jsonOptions.GetTypeInfo<IJsonRpcMessage>()));
|
||||||
}
|
}
|
||||||
|
|
||||||
return _transport.SendMessageAsync(message, cancellationToken);
|
await _transport.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// If the sent notification was a cancellation notification, cancel the pending request's await, as either the
|
||||||
|
// server won't be sending a response, or per the specification, the response should be ignored. There are inherent
|
||||||
|
// race conditions here, so it's possible and allowed for the operation to complete before we get to this point.
|
||||||
|
if (message is JsonRpcNotification { Method: NotificationMethods.CancelledNotification } notification &&
|
||||||
|
GetCancelledNotificationParams(notification.Params) is CancelledNotification cn &&
|
||||||
|
_pendingRequests.TryRemove(cn.RequestId, out var tcs))
|
||||||
|
{
|
||||||
|
tcs.TrySetCanceled(default);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CancelledNotification? GetCancelledNotificationParams(object? notificationParams)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
switch (notificationParams)
|
||||||
|
{
|
||||||
|
case null:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case CancelledNotification cn:
|
||||||
|
return cn;
|
||||||
|
|
||||||
|
case JsonElement je:
|
||||||
|
return JsonSerializer.Deserialize(je, McpJsonUtilities.DefaultOptions.GetTypeInfo<CancelledNotification>());
|
||||||
|
|
||||||
|
default:
|
||||||
|
return JsonSerializer.Deserialize(
|
||||||
|
JsonSerializer.Serialize(notificationParams, McpJsonUtilities.DefaultOptions.GetTypeInfo<object?>()),
|
||||||
|
McpJsonUtilities.DefaultOptions.GetTypeInfo<CancelledNotification>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,7 @@ public static partial class McpJsonUtilities
|
||||||
// MCP Request Params / Results
|
// MCP Request Params / Results
|
||||||
[JsonSerializable(typeof(CallToolRequestParams))]
|
[JsonSerializable(typeof(CallToolRequestParams))]
|
||||||
[JsonSerializable(typeof(CallToolResponse))]
|
[JsonSerializable(typeof(CallToolResponse))]
|
||||||
|
[JsonSerializable(typeof(CancelledNotification))]
|
||||||
[JsonSerializable(typeof(CompleteRequestParams))]
|
[JsonSerializable(typeof(CompleteRequestParams))]
|
||||||
[JsonSerializable(typeof(CompleteResult))]
|
[JsonSerializable(typeof(CompleteResult))]
|
||||||
[JsonSerializable(typeof(CreateMessageRequestParams))]
|
[JsonSerializable(typeof(CreateMessageRequestParams))]
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ public class McpServerBuilderExtensionsToolsTests : LoggedTest, IAsyncDisposable
|
||||||
IMcpClient client = await CreateMcpClientForServer();
|
IMcpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
var tools = await client.ListToolsAsync(TestContext.Current.CancellationToken);
|
var tools = await client.ListToolsAsync(TestContext.Current.CancellationToken);
|
||||||
Assert.Equal(12, tools.Count);
|
Assert.Equal(13, tools.Count);
|
||||||
|
|
||||||
McpClientTool echoTool = tools.First(t => t.Name == "Echo");
|
McpClientTool echoTool = tools.First(t => t.Name == "Echo");
|
||||||
Assert.Equal("Echo", echoTool.Name);
|
Assert.Equal("Echo", echoTool.Name);
|
||||||
|
|
@ -138,7 +138,7 @@ public class McpServerBuilderExtensionsToolsTests : LoggedTest, IAsyncDisposable
|
||||||
cancellationToken: TestContext.Current.CancellationToken))
|
cancellationToken: TestContext.Current.CancellationToken))
|
||||||
{
|
{
|
||||||
var tools = await client.ListToolsAsync(TestContext.Current.CancellationToken);
|
var tools = await client.ListToolsAsync(TestContext.Current.CancellationToken);
|
||||||
Assert.Equal(12, tools.Count);
|
Assert.Equal(13, tools.Count);
|
||||||
|
|
||||||
McpClientTool echoTool = tools.First(t => t.Name == "Echo");
|
McpClientTool echoTool = tools.First(t => t.Name == "Echo");
|
||||||
Assert.Equal("Echo", echoTool.Name);
|
Assert.Equal("Echo", echoTool.Name);
|
||||||
|
|
@ -165,7 +165,7 @@ public class McpServerBuilderExtensionsToolsTests : LoggedTest, IAsyncDisposable
|
||||||
IMcpClient client = await CreateMcpClientForServer();
|
IMcpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
var tools = await client.ListToolsAsync(TestContext.Current.CancellationToken);
|
var tools = await client.ListToolsAsync(TestContext.Current.CancellationToken);
|
||||||
Assert.Equal(12, tools.Count);
|
Assert.Equal(13, tools.Count);
|
||||||
|
|
||||||
Channel<JsonRpcNotification> listChanged = Channel.CreateUnbounded<JsonRpcNotification>();
|
Channel<JsonRpcNotification> listChanged = Channel.CreateUnbounded<JsonRpcNotification>();
|
||||||
client.AddNotificationHandler(NotificationMethods.ToolListChangedNotification, notification =>
|
client.AddNotificationHandler(NotificationMethods.ToolListChangedNotification, notification =>
|
||||||
|
|
@ -186,7 +186,7 @@ public class McpServerBuilderExtensionsToolsTests : LoggedTest, IAsyncDisposable
|
||||||
await notificationRead;
|
await notificationRead;
|
||||||
|
|
||||||
tools = await client.ListToolsAsync(TestContext.Current.CancellationToken);
|
tools = await client.ListToolsAsync(TestContext.Current.CancellationToken);
|
||||||
Assert.Equal(13, tools.Count);
|
Assert.Equal(14, tools.Count);
|
||||||
Assert.Contains(tools, t => t.Name == "NewTool");
|
Assert.Contains(tools, t => t.Name == "NewTool");
|
||||||
|
|
||||||
notificationRead = listChanged.Reader.ReadAsync(TestContext.Current.CancellationToken);
|
notificationRead = listChanged.Reader.ReadAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
@ -195,7 +195,7 @@ public class McpServerBuilderExtensionsToolsTests : LoggedTest, IAsyncDisposable
|
||||||
await notificationRead;
|
await notificationRead;
|
||||||
|
|
||||||
tools = await client.ListToolsAsync(TestContext.Current.CancellationToken);
|
tools = await client.ListToolsAsync(TestContext.Current.CancellationToken);
|
||||||
Assert.Equal(12, tools.Count);
|
Assert.Equal(13, tools.Count);
|
||||||
Assert.DoesNotContain(tools, t => t.Name == "NewTool");
|
Assert.DoesNotContain(tools, t => t.Name == "NewTool");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -560,6 +560,35 @@ public class McpServerBuilderExtensionsToolsTests : LoggedTest, IAsyncDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CancellationNotificationsPropagateToToolTokens()
|
||||||
|
{
|
||||||
|
IMcpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
var tools = await client.ListToolsAsync(TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(tools);
|
||||||
|
Assert.NotEmpty(tools);
|
||||||
|
McpClientTool cancelableTool = tools.First(t => t.Name == nameof(EchoTool.InfiniteCancelableOperation));
|
||||||
|
|
||||||
|
var requestId = new RequestId(Guid.NewGuid().ToString());
|
||||||
|
var invokeTask = client.SendRequestAsync<CallToolResponse>(new JsonRpcRequest()
|
||||||
|
{
|
||||||
|
Method = RequestMethods.ToolsCall,
|
||||||
|
Id = requestId,
|
||||||
|
Params = new CallToolRequestParams() { Name = cancelableTool.ProtocolTool.Name },
|
||||||
|
}, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await client.SendNotificationAsync(
|
||||||
|
NotificationMethods.CancelledNotification,
|
||||||
|
parameters: new CancelledNotification()
|
||||||
|
{
|
||||||
|
RequestId = requestId,
|
||||||
|
},
|
||||||
|
cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => invokeTask);
|
||||||
|
}
|
||||||
|
|
||||||
[McpServerToolType]
|
[McpServerToolType]
|
||||||
public sealed class EchoTool(ObjectWithId objectFromDI)
|
public sealed class EchoTool(ObjectWithId objectFromDI)
|
||||||
{
|
{
|
||||||
|
|
@ -625,6 +654,21 @@ public class McpServerBuilderExtensionsToolsTests : LoggedTest, IAsyncDisposable
|
||||||
return complex.Name!;
|
return complex.Name!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[McpServerTool]
|
||||||
|
public static async Task<string> InfiniteCancelableOperation(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return "canceled";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unreachable";
|
||||||
|
}
|
||||||
|
|
||||||
[McpServerTool]
|
[McpServerTool]
|
||||||
public string GetCtorParameter() => $"{_randomValue}:{objectFromDI.Id}";
|
public string GetCtorParameter() => $"{_randomValue}:{objectFromDI.Id}";
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue