Skip to content
8 changes: 8 additions & 0 deletions src/ModelContextProtocol.Core/McpErrorCode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </remarks>
InternalError = -32603,

/// <summary>
� � /// Indicates that the request was cancelled by the client.
� � /// </summary>
� � /// <remarks>
� � /// This error is returned when the CancellationToken passed with the request is cancelled before processing completes.
� � /// </remarks>
� � RequestCancelled = -32800,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the timeout happens on the server, then this isn't a client cancellation?

}
67 changes: 66 additions & 1 deletion src/ModelContextProtocol.Core/McpSessionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ async Task ProcessMessageAsync()
await HandleMessageAsync(message, combinedCts?.Token ?? cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
{
// Only send responses for request errors that aren't user-initiated cancellation.
bool isUserCancellation =
ex is OperationCanceledException &&
Expand Down Expand Up @@ -301,8 +301,35 @@ private async Task HandleMessageAsync(JsonRpcMessage message, CancellationToken
}
}

/// <summary>
/// Handles inbound JSON-RPC notifications. Special-cases <c>$/cancelRequest</c>
/// to cancel the exact in-flight request, and also supports the SDK's custom
/// <see cref="NotificationMethods.CancelledNotification"/> for backwards compatibility.
/// </summary>
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)
{
Expand Down Expand Up @@ -567,6 +594,44 @@ private Task SendToRelatedTransportAsync(JsonRpcMessage message, CancellationTok
}
}

/// <summary>
/// Parses the <c>id</c> field from a <c>$/cancelRequest</c> notification's params.
/// Returns <see langword="true"/> only when the id is a valid JSON-RPC request id
/// (string or number).
/// </summary>
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<string>());
return true;
}

if (idNode.GetValueKind() == System.Text.Json.JsonValueKind.Number)
{
try
{
var n = idNode.GetValue<long>();
id = new RequestId(n);
return true;
}
catch
{
return false;
}
}

return false;
}

private static string CreateActivityName(string method) => method;

private static string GetMethodName(JsonRpcMessage message) =>
Expand Down
9 changes: 9 additions & 0 deletions src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,13 @@ public static class NotificationMethods
/// </para>
/// </remarks>
public const string CancelledNotification = "notifications/cancelled";

/// <summary>
/// JSON-RPC core cancellation method name (<c>$/cancelRequest</c>).
/// </summary>
/// <remarks>
/// Carries a single <c>id</c> field (string or number) identifying the in-flight
/// request that should be cancelled.
/// </remarks>
public const string JsonRpcCancelRequest = "$/cancelRequest";
}
17 changes: 17 additions & 0 deletions src/ModelContextProtocol.Core/Server/IMcpToolWithTimeout.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace ModelContextProtocol.Server;

/// <summary>
/// Optional contract for tools that expose a per-tool execution timeout.
/// </summary>
/// <remarks>
/// When specified, this value overrides the server-level
/// <see cref="McpServerOptions.DefaultToolTimeout"/> for this tool only.
/// </remarks>
public interface IMcpToolWithTimeout
{
/// <summary>
/// Gets the per-tool timeout. When <see langword="null"/>, the server's
/// default applies (if any).
/// </summary>
TimeSpan? Timeout { get; }
}
73 changes: 73 additions & 0 deletions src/ModelContextProtocol.Core/Server/McpServerImpl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -508,6 +510,12 @@ await originalListPromptsHandler(request, cancellationToken).ConfigureAwait(fals
McpJsonUtilities.JsonContext.Default.GetPromptResult);
}

/// <summary>
/// Wires up tools capability: listing, invocation, DI-provided collections,
/// and the filter pipeline. Invocation enforces per-tool timeouts (when
/// <see cref="IMcpToolWithTimeout.Timeout"/> is set) or falls back to
/// <see cref="McpServerOptions.DefaultToolTimeout"/> when present.
/// </summary>
private void ConfigureTools(McpServerOptions options)
{
var listToolsHandler = options.Handlers.ListToolsHandler;
Expand Down Expand Up @@ -578,8 +586,20 @@ 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)
{
return await RunWithTimeoutAsync(ts, request, cancellationToken, handler);
}

// 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)
Expand Down Expand Up @@ -613,6 +633,59 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false)
McpJsonUtilities.JsonContext.Default.CallToolResult);
}

/// <summary>
/// Executes <paramref name="next"/> with a hard server-side timeout. If the timeout elapses,
/// returns a <see cref="CallToolResult"/> with <c>IsError=true</c> and machine-readable metadata
/// (<c>Meta.IsTimeout=true</c>, <c>Meta.TimeoutMs</c>). Client-initiated cancellations are not
/// handled here; they are rethrown to be processed by the JSON-RPC layer.
/// </summary>
/// <param name="timeout">Must be greater than <see cref="TimeSpan.Zero"/>.</param>
/// <param name="request">The request context.</param>
/// <param name="requestCancellationToken">Outer cancellation (client/network) token.</param>
/// <param name="next">The underlying handler to invoke.</param>
private static async Task<CallToolResult> RunWithTimeoutAsync(
TimeSpan timeout,
RequestContext<CallToolRequestParams> request,
CancellationToken requestCancellationToken,
McpRequestHandler<CallToolRequestParams, CallToolResult> 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 ?? "<unknown>"}' 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.
Expand Down
19 changes: 15 additions & 4 deletions src/ModelContextProtocol.Core/Server/McpServerOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,12 @@ public sealed class McpServerOptions
/// <summary>
/// Gets or sets the container of handlers used by the server for processing protocol messages.
/// </summary>
public McpServerHandlers Handlers
{
public McpServerHandlers Handlers
{
get => field ??= new();
set
{
Throw.IfNull(value);
{
Throw.IfNull(value);
field = value;
}
}
Expand Down Expand Up @@ -166,4 +166,15 @@ public McpServerHandlers Handlers
/// </para>
/// </remarks>
public int MaxSamplingOutputTokens { get; set; } = 1000;

/// <summary>
/// Gets or sets the default timeout applied to tool invocations.
/// </summary>
/// <remarks>
/// When set, the server enforces this timeout for all tools that do not define
/// their own timeout. Tools implementing <see cref="IMcpToolWithTimeout"/> can
/// override this value on a per-tool basis. When <see langword="null"/>, no
/// server-enforced timeout is applied.
/// </remarks>
public TimeSpan? DefaultToolTimeout { get; set; }
}
26 changes: 16 additions & 10 deletions src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,10 @@ public McpServerToolAttribute()
/// The default is <see langword="true"/>.
/// </para>
/// </remarks>
public bool Destructive
public bool Destructive
{
get => _destructive ?? DestructiveDefault;
set => _destructive = value;
get => _destructive ?? DestructiveDefault;
set => _destructive = value;
}

/// <summary>
Expand All @@ -195,10 +195,10 @@ public bool Destructive
/// The default is <see langword="false"/>.
/// </para>
/// </remarks>
public bool Idempotent
public bool Idempotent
{
get => _idempotent ?? IdempotentDefault;
set => _idempotent = value;
set => _idempotent = value;
}

/// <summary>
Expand All @@ -215,8 +215,8 @@ public bool Idempotent
/// </remarks>
public bool OpenWorld
{
get => _openWorld ?? OpenWorldDefault;
set => _openWorld = value;
get => _openWorld ?? OpenWorldDefault;
set => _openWorld = value;
}

/// <summary>
Expand All @@ -235,10 +235,10 @@ public bool OpenWorld
/// The default is <see langword="false"/>.
/// </para>
/// </remarks>
public bool ReadOnly
public bool ReadOnly
{
get => _readOnly ?? ReadOnlyDefault;
set => _readOnly = value;
get => _readOnly ?? ReadOnlyDefault;
set => _readOnly = value;
}

/// <summary>
Expand Down Expand Up @@ -269,4 +269,10 @@ public bool ReadOnly
/// </para>
/// </remarks>
public string? IconSource { get; set; }

/// <summary>
/// Optional timeout for this tool in seconds.
/// If null, the global default (if any) applies.
/// </summary>
public int? TimeoutSeconds { get; set; }
}
Loading