From f5b0e80dd53cd1ba0c26c85a4bd3b5a9e61ed6f4 Mon Sep 17 00:00:00 2001 From: bkarakaya01 Date: Sat, 8 Nov 2025 00:33:21 +0300 Subject: [PATCH 1/7] Add JSON-RPC $/cancelRequest handling with correct server-side cancellation This introduces support for parsing and honoring JSON-RPC \$/cancelRequest\ notifications, making cancellation semantics compliant with MCP protocol expectations. Includes a new \JsonRpcCancelRequest\ constant in \NotificationMethods\ and integrates proper cancellation token linkage inside the server message loop. Additionally: - Added SlowTool test fixture to simulate long-running tools - Implemented full timeout vs external cancellation tests - Normalized English summaries / comments in related areas Fixes #922 --- src/ModelContextProtocol.Core/McpErrorCode.cs | 8 + .../McpSessionHandler.cs | 109 ++++++ .../Protocol/NotificationMethods.cs | 9 + .../Server/IMcpToolWithTimeout.cs | 17 + .../Server/McpServerImpl.cs | 58 ++- .../Server/McpServerOptions.cs | 19 +- .../Server/McpServerToolAttribute.cs | 26 +- .../Server/McpServerToolTimeoutTests.cs | 360 ++++++++++++++++++ 8 files changed, 590 insertions(+), 16 deletions(-) create mode 100644 src/ModelContextProtocol.Core/Server/IMcpToolWithTimeout.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/McpServerToolTimeoutTests.cs diff --git a/src/ModelContextProtocol.Core/McpErrorCode.cs b/src/ModelContextProtocol.Core/McpErrorCode.cs index f6cf4f516..096e830b3 100644 --- a/src/ModelContextProtocol.Core/McpErrorCode.cs +++ b/src/ModelContextProtocol.Core/McpErrorCode.cs @@ -46,4 +46,12 @@ public enum McpErrorCode /// This error is used when the endpoint encounters an unexpected condition that prevents it from fulfilling the request. /// InternalError = -32603, + + /// +    /// Indicates that the request was cancelled by the client. +    /// +    /// +    /// This error is returned when the CancellationToken passed with the request is cancelled before processing completes. +    /// +    RequestCancelled = -32800, } diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index fcd7980d9..5d06d8bd7 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -171,6 +171,15 @@ async Task ProcessMessageAsync() } catch (Exception ex) { + // Fast-path: user-initiated cancellation → emit JSON-RPC RequestCancelled and exit. + if (ex is OperationCanceledException oce + && message is JsonRpcRequest cancelledReq + && IsUserInitiatedCancellation(oce, cancellationToken, combinedCts)) + { + await SendRequestCancelledErrorAsync(cancelledReq, cancellationToken).ConfigureAwait(false); + return; + } + // Only send responses for request errors that aren't user-initiated cancellation. bool isUserCancellation = ex is OperationCanceledException && @@ -301,8 +310,35 @@ private async Task HandleMessageAsync(JsonRpcMessage message, CancellationToken } } + /// + /// Handles inbound JSON-RPC notifications. Special-cases $/cancelRequest + /// to cancel the exact in-flight request, and also supports the SDK's custom + /// for backwards compatibility. + /// private async Task HandleNotification(JsonRpcNotification notification, CancellationToken cancellationToken) { + // Handle JSON-RPC native cancellation: $/cancelRequest + if (notification.Method == NotificationMethods.JsonRpcCancelRequest) + { + try + { + if (TryGetJsonRpcIdFromCancelParams(notification.Params, out var reqId) && + _handlingRequests.TryGetValue(reqId, out var cts)) + { + // Request-specific CTS → cancel the in-flight handler + await cts.CancelAsync().ConfigureAwait(false); + LogRequestCanceled(EndpointName, reqId, reason: "jsonrpc/$/cancelRequest"); + } + } + catch + { + // Per spec, invalid cancel messages should be ignored. + } + + // We do not forward $/cancelRequest to user handlers. + return; + } + // 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) { @@ -567,6 +603,79 @@ private Task SendToRelatedTransportAsync(JsonRpcMessage message, CancellationTok } } + /// + /// Parses the id field from a $/cancelRequest notification's params. + /// Returns only when the id is a valid JSON-RPC request id + /// (string or number). + /// + private static bool TryGetJsonRpcIdFromCancelParams(JsonNode? notificationParams, out RequestId id) + { + id = default; + + if (notificationParams is not JsonObject obj) + return false; + + if (!obj.TryGetPropertyValue("id", out var idNode) || idNode is null) + return false; + + if (idNode.GetValueKind() == System.Text.Json.JsonValueKind.String) + { + id = new RequestId(idNode.GetValue()); + return true; + } + + if (idNode.GetValueKind() == System.Text.Json.JsonValueKind.Number) + { + try + { + var n = idNode.GetValue(); + id = new RequestId(n); + return true; + } + catch + { + return false; + } + } + + return false; + } + + /// + /// Determines whether the caught corresponds + /// to a user-initiated JSON-RPC cancellation (i.e., $/cancelRequest). + /// We distinguish this from global/session shutdown by checking tokens. + /// + private static bool IsUserInitiatedCancellation( + OperationCanceledException _, + CancellationToken sessionOrLoopToken, + CancellationTokenSource? perRequestCts) + { + // User cancellation: per-request CTS is canceled, but the outer/session token is NOT. + return !sessionOrLoopToken.IsCancellationRequested + && perRequestCts?.IsCancellationRequested == true; + } + + /// + /// Sends a standard JSON-RPC RequestCancelled error for the given request. + /// + private Task SendRequestCancelledErrorAsync(JsonRpcRequest request, CancellationToken ct) + { + var error = new JsonRpcError + { + Id = request.Id, + JsonRpc = "2.0", + Error = new JsonRpcErrorDetail + { + Code = (int)McpErrorCode.RequestCancelled, + Message = "Request was cancelled." + }, + Context = new JsonRpcMessageContext { RelatedTransport = request.Context?.RelatedTransport }, + }; + + return SendMessageAsync(error, ct); + } + private static string CreateActivityName(string method) => method; private static string GetMethodName(JsonRpcMessage message) => diff --git a/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs b/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs index 30b7d68a4..20a5641e5 100644 --- a/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs +++ b/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs @@ -131,4 +131,13 @@ public static class NotificationMethods /// /// public const string CancelledNotification = "notifications/cancelled"; + + /// + /// JSON-RPC core cancellation method name ($/cancelRequest). + /// + /// + /// Carries a single id field (string or number) identifying the in-flight + /// request that should be cancelled. + /// + public const string JsonRpcCancelRequest = "$/cancelRequest"; } \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Server/IMcpToolWithTimeout.cs b/src/ModelContextProtocol.Core/Server/IMcpToolWithTimeout.cs new file mode 100644 index 000000000..7f0c4f349 --- /dev/null +++ b/src/ModelContextProtocol.Core/Server/IMcpToolWithTimeout.cs @@ -0,0 +1,17 @@ +namespace ModelContextProtocol.Server; + +/// +/// Optional contract for tools that expose a per-tool execution timeout. +/// +/// +/// When specified, this value overrides the server-level +/// for this tool only. +/// +public interface IMcpToolWithTimeout +{ + /// + /// Gets the per-tool timeout. When , the server's + /// default applies (if any). + /// + TimeSpan? Timeout { get; } +} \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 41408c22b..f44e33a04 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -2,7 +2,9 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ModelContextProtocol.Protocol; +using System.Collections.Concurrent; using System.Runtime.CompilerServices; +using System.Text.Json.Nodes; using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.Server; @@ -41,7 +43,7 @@ internal sealed partial class McpServerImpl : McpServer /// rather than a nullable to be able to manipulate it atomically. /// private StrongBox? _loggingLevel; - + /// /// Creates a new instance of . /// @@ -508,6 +510,12 @@ await originalListPromptsHandler(request, cancellationToken).ConfigureAwait(fals McpJsonUtilities.JsonContext.Default.GetPromptResult); } + /// + /// Wires up tools capability: listing, invocation, DI-provided collections, + /// and the filter pipeline. Invocation enforces per-tool timeouts (when + /// is set) or falls back to + /// when present. + /// private void ConfigureTools(McpServerOptions options) { var listToolsHandler = options.Handlers.ListToolsHandler; @@ -578,12 +586,58 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) request.MatchedPrimitive = tool; } + TimeSpan? effectiveTimeout = null; + try { + // Determine effective timeout: per-tool overrides server default + effectiveTimeout = (request.MatchedPrimitive as IMcpToolWithTimeout)?.Timeout + ?? ServerOptions.DefaultToolTimeout; + + if (effectiveTimeout is { } ts) + { + // Create and link cancellation tokens to enforce the timeout. + // The 'using' statements ensure these disposable resources are cleaned up + // when the try block exits, regardless of success or exception. + using var timeoutCts = new CancellationTokenSource(ts); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + + // Execute the next handler in the pipeline with the linked token. + return await handler(request, linked.Token); + } + + // If no timeout is configured, use the original request cancellation token. return await handler(request, cancellationToken); } - catch (Exception e) when (e is not OperationCanceledException and not McpProtocolException) + catch (Exception e) when (e is not McpProtocolException) { + // Handle Cancellation Exceptions + if (e is OperationCanceledException) + { + // Distinguishing between a server-side timeout and a client-side cancellation + // is necessary here (both use the linked token). + + if (effectiveTimeout.HasValue) // Was a server-side timeout configured? + { + // If a timeout was configured and cancellation occurred, report it as a timeout error. + return new() + { + IsError = true, + + // Machine readable timeout indication in 'Meta' property. + // Required structural data for robust test assertions (checking 'Meta.IsTimeout' instead of parsing the 'Content' string). + Meta = new JsonObject { ["IsTimeout"] = true }, + + Content = [new TextContentBlock { Text = $"Tool '{request.Params?.Name}' timed out after {effectiveTimeout.Value.TotalMilliseconds}ms." }], + }; + } + + // If no server timeout was set, the cancellation must originate from the client's CancellationToken + // passed into RunAsync (or the network layer). We re-throw the exception to allow the + // JSON-RPC handler to process it into a standard protocol-level cancellation error (JsonRpcError). + throw; + } + ToolCallError(request.Params?.Name ?? string.Empty, e); string errorMessage = e is McpException ? diff --git a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs index 7b915b943..2af4e691e 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs @@ -93,12 +93,12 @@ public sealed class McpServerOptions /// /// Gets or sets the container of handlers used by the server for processing protocol messages. /// - public McpServerHandlers Handlers - { + public McpServerHandlers Handlers + { get => field ??= new(); set - { - Throw.IfNull(value); + { + Throw.IfNull(value); field = value; } } @@ -166,4 +166,15 @@ public McpServerHandlers Handlers /// /// public int MaxSamplingOutputTokens { get; set; } = 1000; + + /// + /// Gets or sets the default timeout applied to tool invocations. + /// + /// + /// When set, the server enforces this timeout for all tools that do not define + /// their own timeout. Tools implementing can + /// override this value on a per-tool basis. When , no + /// server-enforced timeout is applied. + /// + public TimeSpan? DefaultToolTimeout { get; set; } } diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs index 9e71e0eab..0cc93742a 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs @@ -177,10 +177,10 @@ public McpServerToolAttribute() /// The default is . /// /// - public bool Destructive + public bool Destructive { - get => _destructive ?? DestructiveDefault; - set => _destructive = value; + get => _destructive ?? DestructiveDefault; + set => _destructive = value; } /// @@ -195,10 +195,10 @@ public bool Destructive /// The default is . /// /// - public bool Idempotent + public bool Idempotent { get => _idempotent ?? IdempotentDefault; - set => _idempotent = value; + set => _idempotent = value; } /// @@ -215,8 +215,8 @@ public bool Idempotent /// public bool OpenWorld { - get => _openWorld ?? OpenWorldDefault; - set => _openWorld = value; + get => _openWorld ?? OpenWorldDefault; + set => _openWorld = value; } /// @@ -235,10 +235,10 @@ public bool OpenWorld /// The default is . /// /// - public bool ReadOnly + public bool ReadOnly { - get => _readOnly ?? ReadOnlyDefault; - set => _readOnly = value; + get => _readOnly ?? ReadOnlyDefault; + set => _readOnly = value; } /// @@ -269,4 +269,10 @@ public bool ReadOnly /// /// public string? IconSource { get; set; } + + /// + /// Optional timeout for this tool in seconds. + /// If null, the global default (if any) applies. + /// + public int? TimeoutSeconds { get; set; } } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTimeoutTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTimeoutTests.cs new file mode 100644 index 000000000..ac5d94685 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTimeoutTests.cs @@ -0,0 +1,360 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using Xunit; + +namespace ModelContextProtocol.Tests.Server; + +// NOTE: Assumes McpServerOptions, McpServer, TestServerTransport, RequestMethods, +// CallToolRequestParams, CallToolResult, JsonRpcMessage, JsonRpcResponse, +// JsonRpcError, McpErrorCode, McpServerTool, Tool, RequestContext, +// TextContentBlock, McpJsonUtilities are available from project references. + +/// +/// A simple test tool that simulates slow work. Used to validate timeout enforcement paths. +/// +public class SlowTool : McpServerTool, IMcpToolWithTimeout +{ + private readonly TimeSpan _workDuration; + private readonly TimeSpan? _toolTimeout; + + public SlowTool(TimeSpan workDuration, TimeSpan? toolTimeout) + { + _workDuration = workDuration; + _toolTimeout = toolTimeout; + } + + public string Name => ProtocolTool.Name; + + /// + public override Tool ProtocolTool => new() + { + Name = "SlowTool", + Description = "A tool that works very slowly.", + // No input parameters; schema must be a non-null empty object. + InputSchema = JsonDocument.Parse("""{"type": "object", "properties": {}}""").RootElement + }; + + /// + public override IReadOnlyList Metadata => Array.Empty(); + + /// + public TimeSpan? Timeout => _toolTimeout; + + /// + /// Simulates long-running work and cooperates with cancellation. + /// + public override async ValueTask InvokeAsync( + RequestContext requestContext, + CancellationToken cancellationToken = default) + { + // If the server injects a timeout-linked token, this will throw on timeout. + await Task.Delay(_workDuration, cancellationToken); + + return new() + { + IsError = false, // <- explicitly success + Content = + [ + new TextContentBlock + { + Text = $"Done after {_workDuration.TotalMilliseconds}ms." + } + ] + }; + } +} + +/// +/// Tests server-side tool timeout enforcement and client-initiated cancellation +/// against a live in-memory transport. +/// +public class McpServerToolTimeoutTests : LoggedTest +{ + public McpServerToolTimeoutTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { } + + private static McpServerOptions CreateOptions(TimeSpan? defaultTimeout = null) + => new() + { + ProtocolVersion = "2024", + InitializationTimeout = TimeSpan.FromSeconds(30), + DefaultToolTimeout = defaultTimeout + }; + + private static async Task InitializeServerAsync( + TestServerTransport transport, + string? protocolVersion, + CancellationToken ct) + { + var initReqId = new RequestId(Guid.NewGuid().ToString("N")); + var initTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void OnInit(JsonRpcMessage m) + { + if (m is JsonRpcResponse r && r.Id.ToString() == initReqId.ToString()) + initTcs.TrySetResult(r); + if (m is JsonRpcError e && e.Id.ToString() == initReqId.ToString()) + initTcs.TrySetException(new Xunit.Sdk.XunitException( + $"initialize returned error. Code={e.Error.Code}, Message='{e.Error.Message}'")); + } + + transport.OnMessageSent += OnInit; + try + { + var initParams = JsonSerializer.SerializeToNode(new + { + protocolVersion, + clientInfo = new { name = "ModelContextProtocol.Tests", version = "0.0.0" }, + capabilities = new { } + }, McpJsonUtilities.DefaultOptions); + + await transport.SendMessageAsync(new JsonRpcRequest + { + Method = RequestMethods.Initialize, + Id = initReqId, + Params = initParams + }, CancellationToken.None); + + _ = await initTcs.Task.WaitAsync(ct); + } + finally + { + transport.OnMessageSent -= OnInit; + } + + await transport.SendMessageAsync(new JsonRpcNotification + { + Method = NotificationMethods.InitializedNotification, + Params = null + }, CancellationToken.None); + } + + + + private async Task ExecuteCallToolRequest( + McpServerOptions options, + string toolName, + CancellationToken externalCancellationToken = default) + { + // Early guard: ensure the tool exists in options.ToolCollection (clear failure if not). + if (options?.ToolCollection is null || !options.ToolCollection.Any(t => t.ProtocolTool.Name == toolName)) + throw new Xunit.Sdk.XunitException($"Tool '{toolName}' is not registered in options.ToolCollection."); + + await using var transport = new TestServerTransport(); + await using var server = McpServer.Create(transport, options, LoggerFactory); + + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + externalCancellationToken, TestContext.Current.CancellationToken); + + var runTask = server.RunAsync(linkedCts.Token); + + // MCP handshake + await InitializeServerAsync(transport, options.ProtocolVersion, linkedCts.Token); + + var reqId = new RequestId(Guid.NewGuid().ToString("N")); + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void OnReply(JsonRpcMessage m) + { + // Success response + if (m is JsonRpcResponse ok && ok.Id.ToString() == reqId.ToString()) + tcs.TrySetResult(ok); + + // Protocol-level error (e.g., "tool not found", validation failures, etc.) + if (m is JsonRpcError err && err.Id.ToString() == reqId.ToString()) + tcs.TrySetException(new Xunit.Sdk.XunitException( + $"Server returned JsonRpcError for tools/call. Code={err.Error.Code}, Message='{err.Error.Message}'")); + } + + transport.OnMessageSent += OnReply; + + try + { + await transport.SendMessageAsync(new JsonRpcRequest + { + Method = RequestMethods.ToolsCall, + Id = reqId, + Params = JsonSerializer.SerializeToNode( + new CallToolRequestParams { Name = toolName }, + McpJsonUtilities.DefaultOptions) + }, externalCancellationToken); + + // This completes for either success (JsonRpcResponse) or error (JsonRpcError). + var obj = await tcs.Task.WaitAsync(externalCancellationToken); + + var response = (JsonRpcResponse)obj; + + // Deserialize a successful response into CallToolResult + return JsonSerializer.Deserialize( + response.Result, McpJsonUtilities.DefaultOptions)!; + } + finally + { + transport.OnMessageSent -= OnReply; + + // Deterministic shutdown + linkedCts.Cancel(); + await transport.DisposeAsync(); + await Task.WhenAny(runTask, Task.Delay(TimeSpan.FromSeconds(5), CancellationToken.None)); + await server.DisposeAsync(); + } + } + + [Fact] + public async Task CallTool_ShouldSucceed_WhenFinishesWithinToolTimeout() + { + // Arrange: 50ms work, 200ms tool timeout → should succeed. + var tool = new SlowTool(TimeSpan.FromMilliseconds(50), TimeSpan.FromMilliseconds(200)); + var options = CreateOptions(); + options.ToolCollection ??= []; + options.ToolCollection.Add(tool); + + // Act + var result = await ExecuteCallToolRequest(options, tool.Name, TestContext.Current.CancellationToken); + + // Assert + Assert.False(result.IsError, "Tool call should succeed when it finishes within the timeout."); + var contentText = result.Content.OfType().Single().Text; + Assert.Contains("Done after 50ms", contentText); + } + + [Fact] + public async Task CallTool_ShouldReturnError_WhenToolTimeoutIsExceeded() + { + // Arrange: 300ms work, 200ms tool timeout → must time out. + var tool = new SlowTool(TimeSpan.FromMilliseconds(300), TimeSpan.FromMilliseconds(200)); + var options = CreateOptions(); + options.ToolCollection ??= []; + options.ToolCollection.Add(tool); + + // Act + var result = await ExecuteCallToolRequest(options, tool.Name, TestContext.Current.CancellationToken); + + // Assert (functional) + Assert.True(result.IsError, "Tool call should fail with IsError=true due to timeout."); + + // Assert (structural): Meta.IsTimeout must be true + Assert.NotNull(result.Meta); + Assert.True( + result.Meta.TryGetPropertyValue("IsTimeout", out var isTimeoutNode), + "Meta must contain 'IsTimeout' property."); + Assert.NotNull(isTimeoutNode); + Assert.True(isTimeoutNode.GetValue(), "'IsTimeout' must be true."); + } + + [Fact] + public async Task CallTool_ShouldReturnError_WhenServerDefaultTimeoutIsExceeded() + { + // Arrange: no per-tool timeout; server default is 100ms; work is 300ms → must time out. + var tool = new SlowTool(TimeSpan.FromMilliseconds(300), toolTimeout: null); + var options = CreateOptions(defaultTimeout: TimeSpan.FromMilliseconds(100)); + options.ToolCollection ??= []; + options.ToolCollection.Add(tool); + + // Act + var result = await ExecuteCallToolRequest(options, tool.Name, TestContext.Current.CancellationToken); + + // Assert (functional) + Assert.True(result.IsError, "Tool call should fail due to the server's default timeout."); + + // Assert (structural): Meta.IsTimeout must be true + Assert.NotNull(result.Meta); + Assert.True( + result.Meta.TryGetPropertyValue("IsTimeout", out var isTimeoutNode), + "Meta must contain 'IsTimeout' property."); + Assert.NotNull(isTimeoutNode); + Assert.True(isTimeoutNode.GetValue(), "'IsTimeout' must be true."); + } + + [Fact] + public async Task CallTool_ShouldReturnJsonRpcError_WhenNoServerTimeoutButClientCancels() + { + // Arrange: no server/tool timeout; we will cancel via JSON-RPC $/cancelRequest. + var tool = new SlowTool(TimeSpan.FromSeconds(10), toolTimeout: null); + var options = CreateOptions(defaultTimeout: null); + options.ToolCollection ??= []; + options.ToolCollection.Add(tool); + + await using var transport = new TestServerTransport(); + await using var server = McpServer.Create(transport, options, LoggerFactory); + + using var serverCts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var runTask = server.RunAsync(serverCts.Token); + + // Handshake so server is ready to accept calls + await InitializeServerAsync(transport, options.ProtocolVersion, serverCts.Token); + + var requestId = new RequestId(Guid.NewGuid().ToString("N")); + + var receivedError = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + transport.OnMessageSent = msg => + { + if (msg is JsonRpcError err && err.Id.ToString() == requestId.ToString()) + receivedError.TrySetResult(err); + }; + + try + { + // 1) Send the tool call (no cancellation token here, ensure it reaches the server). + await transport.SendMessageAsync( + new JsonRpcRequest + { + Method = RequestMethods.ToolsCall, + Id = requestId, + Params = JsonSerializer.SerializeToNode( + new CallToolRequestParams { Name = tool.Name }, + McpJsonUtilities.DefaultOptions) + }, + CancellationToken.None); + + // Give the event loop a chance to dispatch without arbitrary sleeps. + await Task.Yield(); + + await Task.Delay(200, serverCts.Token); + + // 2) Send protocol-level cancellation notification for the above request. + var cancelParams = JsonSerializer.SerializeToNode(new { id = requestId.ToString() }); + + await transport.SendMessageAsync( + new JsonRpcNotification + { + Method = NotificationMethods.JsonRpcCancelRequest, + Params = cancelParams + }, + CancellationToken.None); + + // 3) Bounded await (no infinite hang). If it times out, fail with a clear message. + JsonRpcError error; + try + { + error = await receivedError.Task.WaitAsync(TimeSpan.FromSeconds(5), CancellationToken.None); + } + catch (TimeoutException) + { + throw new Xunit.Sdk.XunitException( + "Server did not emit JsonRpcError(RequestCancelled) within 5s after $/cancelRequest."); + } + + // Assert + Assert.NotNull(error); + Assert.NotNull(error.Error); + Assert.Equal(McpErrorCode.RequestCancelled, (McpErrorCode)error.Error.Code); + } + finally + { + // Deterministic shutdown + serverCts.Cancel(); + + await transport.DisposeAsync(); + + await Task.WhenAny(runTask, Task.Delay(TimeSpan.FromSeconds(5), CancellationToken.None)); + + await server.DisposeAsync(); + } + } +} From c048b4cdaa13ce0c12b41a1199564a7465e78953 Mon Sep 17 00:00:00 2001 From: bkarakaya01 Date: Mon, 10 Nov 2025 12:37:13 +0300 Subject: [PATCH 2/7] refactor: tighten RunWithTimeoutAsync (definitive server timeout detection, machine-readable Meta.TimeoutMs) --- .../Server/McpServerImpl.cs | 66 ++++++++++++++++--- 1 file changed, 56 insertions(+), 10 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index f44e33a04..8ab110c39 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -43,7 +43,7 @@ internal sealed partial class McpServerImpl : McpServer /// rather than a nullable to be able to manipulate it atomically. /// private StrongBox? _loggingLevel; - + /// /// Creates a new instance of . /// @@ -596,14 +596,7 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) if (effectiveTimeout is { } ts) { - // Create and link cancellation tokens to enforce the timeout. - // The 'using' statements ensure these disposable resources are cleaned up - // when the try block exits, regardless of success or exception. - using var timeoutCts = new CancellationTokenSource(ts); - using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); - - // Execute the next handler in the pipeline with the linked token. - return await handler(request, linked.Token); + return await RunWithTimeoutAsync(ts, request, cancellationToken, handler); } // If no timeout is configured, use the original request cancellation token. @@ -627,7 +620,7 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) // Machine readable timeout indication in 'Meta' property. // Required structural data for robust test assertions (checking 'Meta.IsTimeout' instead of parsing the 'Content' string). Meta = new JsonObject { ["IsTimeout"] = true }, - + Content = [new TextContentBlock { Text = $"Tool '{request.Params?.Name}' timed out after {effectiveTimeout.Value.TotalMilliseconds}ms." }], }; } @@ -667,6 +660,59 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) McpJsonUtilities.JsonContext.Default.CallToolResult); } + /// + /// Executes with a hard server-side timeout. If the timeout elapses, + /// returns a with IsError=true and machine-readable metadata + /// (Meta.IsTimeout=true, Meta.TimeoutMs). Client-initiated cancellations are not + /// handled here; they are rethrown to be processed by the JSON-RPC layer. + /// + /// Must be greater than . + /// The request context. + /// Outer cancellation (client/network) token. + /// The underlying handler to invoke. + private static async Task RunWithTimeoutAsync( + TimeSpan timeout, + RequestContext request, + CancellationToken requestCancellationToken, + McpRequestHandler next) + { + if (timeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(timeout), "Timeout must be greater than zero."); + } + + using var timeoutCts = new CancellationTokenSource(timeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(requestCancellationToken, timeoutCts.Token); + + try + { + return await next(request, linkedCts.Token).ConfigureAwait(false); + } + // Definitive server-side timeout: the timeout token fired, while the outer (client) token did not. + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !requestCancellationToken.IsCancellationRequested) + { + var ms = (int)Math.Round(timeout.TotalMilliseconds, MidpointRounding.AwayFromZero); + + return new CallToolResult + { + IsError = true, + Meta = new System.Text.Json.Nodes.JsonObject + { + ["IsTimeout"] = true, + ["TimeoutMs"] = ms, + }, + Content = + [ + new TextContentBlock + { + Text = $"Tool '{request.Params?.Name ?? ""}' timed out after {ms}ms." + } + ], + }; + } + } + + private void ConfigureLogging(McpServerOptions options) { // We don't require that the handler be provided, as we always store the provided log level to the server. From 0800a624f6fdc62b6b829f2379d51f386998dda7 Mon Sep 17 00:00:00 2001 From: bkarakaya01 Date: Mon, 10 Nov 2025 12:44:19 +0300 Subject: [PATCH 3/7] fix: clarify user-initiated cancellation path in McpSessionHandler (per review feedback) --- .../McpSessionHandler.cs | 26 +------------------ 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index 5d06d8bd7..39c2a0340 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -170,16 +170,7 @@ async Task ProcessMessageAsync() await HandleMessageAsync(message, combinedCts?.Token ?? cancellationToken).ConfigureAwait(false); } catch (Exception ex) - { - // Fast-path: user-initiated cancellation → emit JSON-RPC RequestCancelled and exit. - if (ex is OperationCanceledException oce - && message is JsonRpcRequest cancelledReq - && IsUserInitiatedCancellation(oce, cancellationToken, combinedCts)) - { - await SendRequestCancelledErrorAsync(cancelledReq, cancellationToken).ConfigureAwait(false); - return; - } - + { // Only send responses for request errors that aren't user-initiated cancellation. bool isUserCancellation = ex is OperationCanceledException && @@ -641,21 +632,6 @@ private static bool TryGetJsonRpcIdFromCancelParams(JsonNode? notificationParams return false; } - /// - /// Determines whether the caught corresponds - /// to a user-initiated JSON-RPC cancellation (i.e., $/cancelRequest). - /// We distinguish this from global/session shutdown by checking tokens. - /// - private static bool IsUserInitiatedCancellation( - OperationCanceledException _, - CancellationToken sessionOrLoopToken, - CancellationTokenSource? perRequestCts) - { - // User cancellation: per-request CTS is canceled, but the outer/session token is NOT. - return !sessionOrLoopToken.IsCancellationRequested - && perRequestCts?.IsCancellationRequested == true; - } - /// /// Sends a standard JSON-RPC RequestCancelled error for the given request. /// From b562769e6119e95210837f8b8299a25088e2f546 Mon Sep 17 00:00:00 2001 From: bkarakaya01 Date: Mon, 10 Nov 2025 12:53:22 +0300 Subject: [PATCH 4/7] test(server): assert Meta.TimeoutMs and rely on negotiated protocol version; ensure silent $/cancelRequest path --- .../Server/McpServerToolTimeoutTests.cs | 48 ++++++++----------- 1 file changed, 21 insertions(+), 27 deletions(-) diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTimeoutTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTimeoutTests.cs index ac5d94685..58c57ff49 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTimeoutTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTimeoutTests.cs @@ -271,9 +271,9 @@ public async Task CallTool_ShouldReturnError_WhenServerDefaultTimeoutIsExceeded( } [Fact] - public async Task CallTool_ShouldReturnJsonRpcError_WhenNoServerTimeoutButClientCancels() + public async Task CallTool_ShouldNotRespond_WhenClientCancelsViaJsonRpc() { - // Arrange: no server/tool timeout; we will cancel via JSON-RPC $/cancelRequest. + // Arrange: no server/tool timeout; user will cancel via $/cancelRequest. var tool = new SlowTool(TimeSpan.FromSeconds(10), toolTimeout: null); var options = CreateOptions(defaultTimeout: null); options.ToolCollection ??= []; @@ -285,22 +285,27 @@ public async Task CallTool_ShouldReturnJsonRpcError_WhenNoServerTimeoutButClient using var serverCts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); var runTask = server.RunAsync(serverCts.Token); - // Handshake so server is ready to accept calls + // handshake await InitializeServerAsync(transport, options.ProtocolVersion, serverCts.Token); var requestId = new RequestId(Guid.NewGuid().ToString("N")); - var receivedError = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var anyReply = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - transport.OnMessageSent = msg => + void OnAnyReply(JsonRpcMessage m) { - if (msg is JsonRpcError err && err.Id.ToString() == requestId.ToString()) - receivedError.TrySetResult(err); - }; + if ((m is JsonRpcResponse r && r.Id.ToString() == requestId.ToString()) || + (m is JsonRpcError e && e.Id.ToString() == requestId.ToString())) + { + anyReply.TrySetResult(m); + } + } + + transport.OnMessageSent += OnAnyReply; try { - // 1) Send the tool call (no cancellation token here, ensure it reaches the server). + // 1) send call await transport.SendMessageAsync( new JsonRpcRequest { @@ -312,14 +317,11 @@ await transport.SendMessageAsync( }, CancellationToken.None); - // Give the event loop a chance to dispatch without arbitrary sleeps. await Task.Yield(); - await Task.Delay(200, serverCts.Token); - // 2) Send protocol-level cancellation notification for the above request. + // 2) send $/cancelRequest var cancelParams = JsonSerializer.SerializeToNode(new { id = requestId.ToString() }); - await transport.SendMessageAsync( new JsonRpcNotification { @@ -328,33 +330,25 @@ await transport.SendMessageAsync( }, CancellationToken.None); - // 3) Bounded await (no infinite hang). If it times out, fail with a clear message. - JsonRpcError error; + // 3) ensure that NO response is emitted for this cancellation try { - error = await receivedError.Task.WaitAsync(TimeSpan.FromSeconds(5), CancellationToken.None); + var _ = await anyReply.Task.WaitAsync(TimeSpan.FromSeconds(2), CancellationToken.None); + throw new Xunit.Sdk.XunitException("Server responded to user-initiated cancellation. Expected: no response."); } catch (TimeoutException) { - throw new Xunit.Sdk.XunitException( - "Server did not emit JsonRpcError(RequestCancelled) within 5s after $/cancelRequest."); + // expected → silent cancel path } - - // Assert - Assert.NotNull(error); - Assert.NotNull(error.Error); - Assert.Equal(McpErrorCode.RequestCancelled, (McpErrorCode)error.Error.Code); } finally { - // Deterministic shutdown + transport.OnMessageSent -= OnAnyReply; serverCts.Cancel(); - await transport.DisposeAsync(); - await Task.WhenAny(runTask, Task.Delay(TimeSpan.FromSeconds(5), CancellationToken.None)); - await server.DisposeAsync(); } } + } From 48ad945b4ebe88b246106535b983415d650048bd Mon Sep 17 00:00:00 2001 From: bkarakaya01 Date: Mon, 10 Nov 2025 13:03:13 +0300 Subject: [PATCH 5/7] chore: remove unused RequestCancelled helper (silent $/cancelRequest path; no protocol error emitted) --- .../McpSessionHandler.cs | 22 +------------------ 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index 39c2a0340..a57828721 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -630,27 +630,7 @@ private static bool TryGetJsonRpcIdFromCancelParams(JsonNode? notificationParams } return false; - } - - /// - /// Sends a standard JSON-RPC RequestCancelled error for the given request. - /// - private Task SendRequestCancelledErrorAsync(JsonRpcRequest request, CancellationToken ct) - { - var error = new JsonRpcError - { - Id = request.Id, - JsonRpc = "2.0", - Error = new JsonRpcErrorDetail - { - Code = (int)McpErrorCode.RequestCancelled, - Message = "Request was cancelled." - }, - Context = new JsonRpcMessageContext { RelatedTransport = request.Context?.RelatedTransport }, - }; - - return SendMessageAsync(error, ct); - } + } private static string CreateActivityName(string method) => method; From 3e07f0dc97864c574034f9217e57454d5288138f Mon Sep 17 00:00:00 2001 From: bkarakaya01 Date: Mon, 10 Nov 2025 13:21:47 +0300 Subject: [PATCH 6/7] refactor(server): rethrow OperationCanceledException to let JSON-RPC emit standard cancel; keep server timeouts in RunWithTimeoutAsync --- .../Server/McpServerImpl.cs | 29 +------------------ 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 8ab110c39..deb245086 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -602,35 +602,8 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) // If no timeout is configured, use the original request cancellation token. return await handler(request, cancellationToken); } - catch (Exception e) when (e is not McpProtocolException) + catch (Exception e) when (e is not OperationCanceledException and not McpProtocolException) { - // Handle Cancellation Exceptions - if (e is OperationCanceledException) - { - // Distinguishing between a server-side timeout and a client-side cancellation - // is necessary here (both use the linked token). - - if (effectiveTimeout.HasValue) // Was a server-side timeout configured? - { - // If a timeout was configured and cancellation occurred, report it as a timeout error. - return new() - { - IsError = true, - - // Machine readable timeout indication in 'Meta' property. - // Required structural data for robust test assertions (checking 'Meta.IsTimeout' instead of parsing the 'Content' string). - Meta = new JsonObject { ["IsTimeout"] = true }, - - Content = [new TextContentBlock { Text = $"Tool '{request.Params?.Name}' timed out after {effectiveTimeout.Value.TotalMilliseconds}ms." }], - }; - } - - // If no server timeout was set, the cancellation must originate from the client's CancellationToken - // passed into RunAsync (or the network layer). We re-throw the exception to allow the - // JSON-RPC handler to process it into a standard protocol-level cancellation error (JsonRpcError). - throw; - } - ToolCallError(request.Params?.Name ?? string.Empty, e); string errorMessage = e is McpException ? From 390aefc56a17d6e7b5306f79f0efe9e61f7920db Mon Sep 17 00:00:00 2001 From: bkarakaya01 Date: Fri, 14 Nov 2025 00:27:35 +0300 Subject: [PATCH 7/7] docs: clarify semantics of RequestCancelled (-32800) The XML documentation for `McpErrorCode.RequestCancelled` has been updated to clearly indicate that this error code is reserved for user-initiated cancellation (e.g., a client sending a JSON-RPC `$/cancelRequest`). The C# server implementation does not emit this code for server-side timeouts. Timeouts are surfaced as regular CallToolResult errors with timeout metadata (`Meta["IsTimeout"]`, `Meta["TimeoutMs"]`). This aligns with reviewer feedback and ensures a clean separation between client cancellation semantics and server timeout behavior. --- src/ModelContextProtocol.Core/McpErrorCode.cs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/ModelContextProtocol.Core/McpErrorCode.cs b/src/ModelContextProtocol.Core/McpErrorCode.cs index 096e830b3..107bc0aaf 100644 --- a/src/ModelContextProtocol.Core/McpErrorCode.cs +++ b/src/ModelContextProtocol.Core/McpErrorCode.cs @@ -48,10 +48,14 @@ public enum McpErrorCode InternalError = -32603, /// -    /// Indicates that the request was cancelled by the client. -    /// -    /// -    /// This error is returned when the CancellationToken passed with the request is cancelled before processing completes. -    /// -    RequestCancelled = -32800, + /// Indicates that a request was explicitly cancelled by the caller before completion. + /// + /// + /// MCP-specific error code (-32800) reserved to represent user-initiated cancellation + /// (for example, when a client sends a JSON-RPC $/cancelRequest for an in-flight call). + /// This value is not used by the current C# server implementation for server-side timeouts; + /// timeouts are surfaced as regular + /// errors with timeout metadata (for example, Meta["IsTimeout"] = true and Meta["TimeoutMs"]). + /// + RequestCancelled = -32800, }