diff --git a/src/ModelContextProtocol.Core/McpErrorCode.cs b/src/ModelContextProtocol.Core/McpErrorCode.cs
index f6cf4f51..096e830b 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 fcd7980d..5d06d8bd 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 30b7d68a..20a5641e 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 00000000..7f0c4f34
--- /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 41408c22..f44e33a0 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 7b915b94..2af4e691 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 9e71e0ea..0cc93742 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 00000000..ac5d9468
--- /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