Implement SEP-2663 Tasks Extension (#1579)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
ed07fdb6f4
commit
dbb7a20176
14 changed files with 8190 additions and 11405 deletions
|
|
@ -154,23 +154,6 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
|
|||
tool.Meta = function.UnderlyingMethod is not null ?
|
||||
CreateMetaFromAttributes(function.UnderlyingMethod, options.Meta) :
|
||||
options.Meta;
|
||||
|
||||
// Apply user-specified Execution settings if provided
|
||||
if (options.Execution is not null)
|
||||
{
|
||||
tool.Execution = options.Execution;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect async methods and mark with taskSupport = "optional" unless explicitly configured.
|
||||
// This enables implicit task support for async tools: clients can choose to invoke them
|
||||
// synchronously (wait for completion) or as a task (receive taskId, poll for result).
|
||||
if (function.UnderlyingMethod is not null &&
|
||||
IsAsyncMethod(function.UnderlyingMethod) &&
|
||||
tool.Execution?.TaskSupport is null)
|
||||
{
|
||||
tool.Execution ??= new ToolExecution();
|
||||
tool.Execution.TaskSupport = ToolTaskSupport.Optional;
|
||||
}
|
||||
|
||||
return new AIFunctionMcpServerTool(function, tool, options?.Services, structuredOutputRequiresWrapping, options?.Metadata ?? []);
|
||||
|
|
@ -218,12 +201,6 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
|
|||
serializerOptions: newOptions.SerializerOptions ?? McpJsonUtilities.DefaultOptions,
|
||||
inferenceOptions: newOptions.SchemaCreateOptions);
|
||||
}
|
||||
|
||||
if (toolAttr._taskSupport is { } taskSupport)
|
||||
{
|
||||
newOptions.Execution ??= new ToolExecution();
|
||||
newOptions.Execution.TaskSupport ??= taskSupport;
|
||||
}
|
||||
}
|
||||
|
||||
if (method.GetCustomAttribute<DescriptionAttribute>() is { } descAttr)
|
||||
|
|
@ -350,27 +327,27 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
|
|||
|
||||
// Case the name based on the provided naming policy.
|
||||
return (policy ?? JsonNamingPolicy.SnakeCaseLower).ConvertName(name) ?? name;
|
||||
}
|
||||
|
||||
private static bool IsAsyncMethod(MethodInfo method)
|
||||
{
|
||||
Type t = method.ReturnType;
|
||||
|
||||
if (t == typeof(Task) || t == typeof(ValueTask))
|
||||
static bool IsAsyncMethod(MethodInfo method)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
Type t = method.ReturnType;
|
||||
|
||||
if (t.IsGenericType)
|
||||
{
|
||||
t = t.GetGenericTypeDefinition();
|
||||
if (t == typeof(Task<>) || t == typeof(ValueTask<>) || t == typeof(IAsyncEnumerable<>))
|
||||
if (t == typeof(Task) || t == typeof(ValueTask))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
if (t.IsGenericType)
|
||||
{
|
||||
t = t.GetGenericTypeDefinition();
|
||||
if (t == typeof(Task<>) || t == typeof(ValueTask<>) || t == typeof(IAsyncEnumerable<>))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Creates metadata from attributes on the specified method and its declaring class, with the MethodInfo as the first item.</summary>
|
||||
|
|
|
|||
|
|
@ -2,165 +2,155 @@ using ModelContextProtocol.Protocol;
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ModelContextProtocol;
|
||||
namespace ModelContextProtocol.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an interface for pluggable task storage implementations in MCP servers.
|
||||
/// Provides an interface for storing and managing the lifecycle of MCP tasks.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The task store is responsible for managing the lifecycle of tasks, including creation,
|
||||
/// status updates, result storage, and retrieval. Implementations must be thread-safe and
|
||||
/// may support session-based isolation for multi-session scenarios.
|
||||
/// The task store manages the state of tasks created by the server's request handling pipeline.
|
||||
/// When a client signals support for the <c>io.modelcontextprotocol/tasks</c> extension on a request,
|
||||
/// the server creates a task in the store, executes the work in the background, and stores the result
|
||||
/// upon completion.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// TTL (Time To Live) Management: Implementations may override the requested TTL value in
|
||||
/// <see cref="McpTaskMetadata.TimeToLive"/> to enforce resource limits. The actual TTL
|
||||
/// used is returned in the <see cref="McpTask.TimeToLive"/> property. A null TTL indicates
|
||||
/// unlimited lifetime. Tasks may be deleted after their TTL expires, regardless of status.
|
||||
/// Implementations must be thread-safe. The store also provides the backing implementation for
|
||||
/// <c>tasks/get</c>, <c>tasks/update</c>, and <c>tasks/cancel</c> protocol methods.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Lifetime under stateless HTTP:</b> when the server is configured for stateless HTTP
|
||||
/// (each request creates a fresh server instance), the same <see cref="IMcpTaskStore"/> instance
|
||||
/// MUST be shared across requests — either by registering the store as a singleton in the DI
|
||||
/// container, or by backing it with external storage (database, distributed cache, etc.) that
|
||||
/// every server instance can reach. Otherwise <c>tasks/get</c> polls issued on subsequent
|
||||
/// requests will see an empty in-memory store and never find the task they are polling for.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// See the <see href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md">SEP-2663</see>
|
||||
/// specification for details on the tasks extension.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
|
||||
[Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)]
|
||||
public interface IMcpTaskStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new task for tracking an asynchronous operation.
|
||||
/// Creates a new task for tracking an asynchronous execution.
|
||||
/// </summary>
|
||||
/// <param name="taskParams">Metadata for the task, including requested TTL.</param>
|
||||
/// <param name="requestId">The JSON-RPC request ID that initiated this task.</param>
|
||||
/// <param name="request">The original JSON-RPC request that triggered task creation.</param>
|
||||
/// <param name="sessionId">Optional session identifier for multi-session isolation.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>
|
||||
/// A new <see cref="McpTask"/> with a unique task ID, initial status of <see cref="McpTaskStatus.Working"/>,
|
||||
/// and the actual TTL that will be used (which may differ from the requested TTL).
|
||||
/// A <see cref="McpTaskInfo"/> with a unique task ID, initial status of <see cref="McpTaskStatus.Working"/>,
|
||||
/// and timing metadata (TTL, poll interval).
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Implementations must generate a unique task ID and set the <see cref="McpTask.CreatedAt"/>
|
||||
/// and <see cref="McpTask.LastUpdatedAt"/> timestamps. The implementation may override the
|
||||
/// requested TTL to enforce storage limits.
|
||||
/// <para>
|
||||
/// Implementations must generate a unique task ID and set appropriate timestamps.
|
||||
/// The server infrastructure maps the returned <see cref="McpTaskInfo"/> to the appropriate
|
||||
/// protocol response type when communicating with clients.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Per the MCP specification (SEP-2663 §306), the returned task MUST be durably created
|
||||
/// before this method completes: a subsequent <see cref="GetTaskAsync"/> with the returned
|
||||
/// <see cref="McpTaskInfo.TaskId"/> MUST resolve, even if it runs on a different process or
|
||||
/// node. Implementations backed by eventually-consistent storage must therefore wait for the
|
||||
/// write to be visible (e.g., quorum acknowledgement, write-through, or an equivalent
|
||||
/// barrier) before returning.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
Task<McpTask> CreateTaskAsync(
|
||||
McpTaskMetadata taskParams,
|
||||
RequestId requestId,
|
||||
JsonRpcRequest request,
|
||||
string? sessionId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<McpTaskInfo> CreateTaskAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a task by its unique identifier.
|
||||
/// Retrieves the current state of a task.
|
||||
/// </summary>
|
||||
/// <param name="taskId">The unique identifier of the task to retrieve.</param>
|
||||
/// <param name="sessionId">Optional session identifier for access control.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>
|
||||
/// The <see cref="McpTask"/> if found and accessible, otherwise <see langword="null"/>.
|
||||
/// A <see cref="McpTaskInfo"/> representing the current task state,
|
||||
/// or <see langword="null"/> if the task does not exist.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Returns null if the task does not exist or if session-based access control denies access.
|
||||
/// </remarks>
|
||||
Task<McpTask?> GetTaskAsync(string taskId, string? sessionId = null, CancellationToken cancellationToken = default);
|
||||
Task<McpTaskInfo?> GetTaskAsync(string taskId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Stores the final result of a task that has reached a terminal status.
|
||||
/// Stores the result of a completed execution, transitioning the task to <see cref="McpTaskStatus.Completed"/>.
|
||||
/// </summary>
|
||||
/// <param name="taskId">The unique identifier of the task.</param>
|
||||
/// <param name="status">The terminal status: <see cref="McpTaskStatus.Completed"/> or <see cref="McpTaskStatus.Failed"/>.</param>
|
||||
/// <param name="result">The operation result to store as a JSON element.</param>
|
||||
/// <param name="sessionId">Optional session identifier for access control.</param>
|
||||
/// <param name="result">The serialized result payload.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>The updated <see cref="McpTask"/> with the new status and result stored.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The <paramref name="status"/> must be either <see cref="McpTaskStatus.Completed"/> or
|
||||
/// <see cref="McpTaskStatus.Failed"/>. This method updates the task status and stores
|
||||
/// the result for later retrieval via <see cref="GetTaskResultAsync"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Implementations should throw <see cref="InvalidOperationException"/> if called on a task
|
||||
/// that is already in a terminal state, to prevent result overwrites.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
Task<McpTask> StoreTaskResultAsync(
|
||||
string taskId,
|
||||
McpTaskStatus status,
|
||||
JsonElement result,
|
||||
string? sessionId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
Task SetCompletedAsync(string taskId, JsonElement result, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the stored result of a completed or failed task.
|
||||
/// Marks a task as failed, transitioning it to <see cref="McpTaskStatus.Failed"/>.
|
||||
/// </summary>
|
||||
/// <param name="taskId">The unique identifier of the task.</param>
|
||||
/// <param name="sessionId">Optional session identifier for access control.</param>
|
||||
/// <param name="error">The serialized error information.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>The stored operation result as a JSON element.</returns>
|
||||
/// <remarks>
|
||||
/// This method should only be called on tasks in terminal states (<see cref="McpTaskStatus.Completed"/>
|
||||
/// or <see cref="McpTaskStatus.Failed"/>). The result contains the JSON representation of the
|
||||
/// original operation result (e.g., <see cref="CallToolResult"/> for tools/call).
|
||||
/// </remarks>
|
||||
Task<JsonElement> GetTaskResultAsync(string taskId, string? sessionId = null, CancellationToken cancellationToken = default);
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
Task SetFailedAsync(string taskId, JsonElement error, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the status and optional status message of a task.
|
||||
/// </summary>
|
||||
/// <param name="taskId">The unique identifier of the task.</param>
|
||||
/// <param name="status">The new status to set.</param>
|
||||
/// <param name="statusMessage">Optional diagnostic message describing the status change.</param>
|
||||
/// <param name="sessionId">Optional session identifier for access control.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>The updated <see cref="McpTask"/> with the new status applied.</returns>
|
||||
/// <remarks>
|
||||
/// This method updates the task's <see cref="McpTask.Status"/>, <see cref="McpTask.StatusMessage"/>,
|
||||
/// and <see cref="McpTask.LastUpdatedAt"/> properties. Common uses include transitioning to
|
||||
/// <see cref="McpTaskStatus.Cancelled"/>, <see cref="McpTaskStatus.InputRequired"/>, or updating
|
||||
/// progress messages while in <see cref="McpTaskStatus.Working"/> status.
|
||||
/// </remarks>
|
||||
Task<McpTask> UpdateTaskStatusAsync(
|
||||
string taskId,
|
||||
McpTaskStatus status,
|
||||
string? statusMessage,
|
||||
string? sessionId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists tasks with pagination support.
|
||||
/// </summary>
|
||||
/// <param name="cursor">Optional cursor for pagination, from a previous call's nextCursor value.</param>
|
||||
/// <param name="sessionId">Optional session identifier for filtering tasks by session.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A <see cref="ListTasksResult"/> containing the tasks and an optional cursor for the next page.</returns>
|
||||
/// <remarks>
|
||||
/// When <paramref name="sessionId"/> is provided, implementations should filter to only return
|
||||
/// tasks associated with that session. The cursor format is implementation-specific.
|
||||
/// </remarks>
|
||||
Task<ListTasksResult> ListTasksAsync(
|
||||
string? cursor = null,
|
||||
string? sessionId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to cancel a task, transitioning it to <see cref="McpTaskStatus.Cancelled"/> status.
|
||||
/// Transitions the task to <see cref="McpTaskStatus.Cancelled"/>.
|
||||
/// </summary>
|
||||
/// <param name="taskId">The unique identifier of the task to cancel.</param>
|
||||
/// <param name="sessionId">Optional session identifier for access control.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>
|
||||
/// The updated <see cref="McpTask"/>. If the task is already in a terminal state
|
||||
/// (<see cref="McpTaskStatus.Completed"/>, <see cref="McpTaskStatus.Failed"/>, or
|
||||
/// <see cref="McpTaskStatus.Cancelled"/>), the task is returned unchanged.
|
||||
/// <see langword="true"/> if the task was successfully cancelled;
|
||||
/// <see langword="false"/> if the task does not exist or was already in a terminal state.
|
||||
/// </returns>
|
||||
Task<bool> SetCancelledAsync(string taskId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes input requests that have been satisfied by the provided responses and
|
||||
/// raises <see cref="InputResponseReceived"/> for each resolved entry.
|
||||
/// </summary>
|
||||
/// <param name="taskId">The unique identifier of the task.</param>
|
||||
/// <param name="inputResponses">
|
||||
/// The input responses keyed by the original request identifier.
|
||||
/// Matched input requests are removed from the task's pending set.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This method must be idempotent. If called on a task that is already in a terminal state,
|
||||
/// it returns the current task without error. This behavior differs from the MCP specification
|
||||
/// but ensures idempotency and avoids race conditions between cancellation and task completion.
|
||||
/// After removing the satisfied requests, if no pending input requests remain the task
|
||||
/// transitions back to <see cref="McpTaskStatus.Working"/>. Otherwise it remains in
|
||||
/// <see cref="McpTaskStatus.InputRequired"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// For tasks not in a terminal state, the implementation should attempt to stop the underlying
|
||||
/// operation and transition the task to <see cref="McpTaskStatus.Cancelled"/> status before returning.
|
||||
/// Implementations must raise <see cref="InputResponseReceived"/> for each entry in
|
||||
/// <paramref name="inputResponses"/> after updating the store state. In distributed
|
||||
/// deployments, this event enables the originating server to be notified even if a
|
||||
/// different server instance processes the <c>tasks/update</c> request.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
Task<McpTask> CancelTaskAsync(string taskId, string? sessionId = null, CancellationToken cancellationToken = default);
|
||||
Task ResolveInputRequestsAsync(
|
||||
string taskId,
|
||||
IDictionary<string, InputResponse> inputResponses,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when an input response is resolved for a task.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Implementations must raise this event for each input response resolved in
|
||||
/// <see cref="ResolveInputRequestsAsync"/>. Subscribers use this to complete
|
||||
/// pending input request waiters (e.g., elicitation or sampling calls that are
|
||||
/// awaiting a client response).
|
||||
/// </remarks>
|
||||
event Action<InputResponseReceivedEventArgs>? InputResponseReceived;
|
||||
|
||||
/// <summary>
|
||||
/// Adds input requests to a task, transitioning it to <see cref="McpTaskStatus.InputRequired"/>.
|
||||
/// </summary>
|
||||
/// <param name="taskId">The unique identifier of the task.</param>
|
||||
/// <param name="inputRequests">
|
||||
/// The input requests to add. Keys are arbitrary identifiers for matching requests to responses.
|
||||
/// Each value is an <see cref="InputRequest"/> wrapping the server-to-client request payload.
|
||||
/// New requests are merged with any existing pending requests.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
Task SetInputRequestsAsync(
|
||||
string taskId,
|
||||
IDictionary<string, InputRequest> inputRequests,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,543 +1,222 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
|
||||
#if MCP_TEST_TIME_PROVIDER
|
||||
namespace ModelContextProtocol.Tests.Internal;
|
||||
#else
|
||||
namespace ModelContextProtocol;
|
||||
#endif
|
||||
namespace ModelContextProtocol.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an in-memory implementation of <see cref="IMcpTaskStore"/> for development and testing.
|
||||
/// Provides an in-memory implementation of <see cref="IMcpTaskStore"/> for development and testing scenarios.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This implementation uses thread-safe concurrent collections and is suitable for single-server
|
||||
/// scenarios and testing. It is not recommended for production multi-server deployments as tasks
|
||||
/// are stored only in memory and are lost on server restart.
|
||||
/// This implementation stores all task state in memory using immutable snapshots and
|
||||
/// compare-and-swap updates for thread safety without locks.
|
||||
/// Tasks are not persisted across process restarts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Features:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Thread-safe operations using <see cref="ConcurrentDictionary{TKey, TValue}"/></description></item>
|
||||
/// <item><description>Automatic TTL-based cleanup via background task</description></item>
|
||||
/// <item><description>Session-based isolation when sessionId is provided</description></item>
|
||||
/// <item><description>Configurable default TTL and maximum TTL limits</description></item>
|
||||
/// </list>
|
||||
/// For production scenarios requiring durability, session isolation, or TTL-based cleanup,
|
||||
/// implement a custom <see cref="IMcpTaskStore"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
|
||||
public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
|
||||
[Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)]
|
||||
public class InMemoryMcpTaskStore : IMcpTaskStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, TaskEntry> _tasks = new();
|
||||
private readonly TimeSpan? _defaultTtl;
|
||||
private readonly TimeSpan? _maxTtl;
|
||||
private readonly TimeSpan _pollInterval;
|
||||
#if MCP_TEST_TIME_PROVIDER
|
||||
private readonly ITimer? _cleanupTimer;
|
||||
#else
|
||||
private readonly Timer? _cleanupTimer;
|
||||
#endif
|
||||
private readonly int _pageSize;
|
||||
private readonly int? _maxTasks;
|
||||
private readonly int? _maxTasksPerSession;
|
||||
#if MCP_TEST_TIME_PROVIDER
|
||||
private readonly TimeProvider _timeProvider;
|
||||
#endif
|
||||
private readonly ConcurrentDictionary<string, McpTaskInfo> _tasks = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InMemoryMcpTaskStore"/> class.
|
||||
/// Gets or sets the default poll interval in milliseconds for new tasks.
|
||||
/// </summary>
|
||||
/// <param name="defaultTtl">
|
||||
/// Default TTL to use when task creation does not specify a TTL. Null means unlimited.
|
||||
/// </param>
|
||||
/// <param name="maxTtl">
|
||||
/// Maximum TTL allowed. If a task requests a longer TTL, it will be capped to this value.
|
||||
/// Null means no maximum limit.
|
||||
/// </param>
|
||||
/// <param name="pollInterval">
|
||||
/// Advertised polling interval for tasks. Default is 1 second.
|
||||
/// This value is used when creating new tasks to indicate how frequently clients should poll for updates.
|
||||
/// </param>
|
||||
/// <param name="cleanupInterval">
|
||||
/// Interval for running background cleanup of expired tasks. Default is 1 minute.
|
||||
/// Pass <see cref="Timeout.InfiniteTimeSpan"/> to disable automatic cleanup.
|
||||
/// </param>
|
||||
/// <param name="pageSize">
|
||||
/// Maximum number of tasks to return per page in <see cref="ListTasksAsync"/>. Default is 100.
|
||||
/// </param>
|
||||
/// <param name="maxTasks">
|
||||
/// Maximum number of tasks allowed in the store globally. Null means unlimited.
|
||||
/// When the limit is reached, <see cref="CreateTaskAsync"/> will throw <see cref="InvalidOperationException"/>.
|
||||
/// </param>
|
||||
/// <param name="maxTasksPerSession">
|
||||
/// Maximum number of tasks allowed per session. Null means unlimited.
|
||||
/// When the limit is reached for a session, <see cref="CreateTaskAsync"/> will throw <see cref="InvalidOperationException"/>.
|
||||
/// </param>
|
||||
public InMemoryMcpTaskStore(
|
||||
TimeSpan? defaultTtl = null,
|
||||
TimeSpan? maxTtl = null,
|
||||
TimeSpan? pollInterval = null,
|
||||
TimeSpan? cleanupInterval = null,
|
||||
int pageSize = 100,
|
||||
int? maxTasks = null,
|
||||
int? maxTasksPerSession = null)
|
||||
{
|
||||
if (defaultTtl.HasValue && maxTtl.HasValue && defaultTtl.Value > maxTtl.Value)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Default TTL ({defaultTtl.Value}) cannot exceed maximum TTL ({maxTtl.Value}).",
|
||||
nameof(defaultTtl));
|
||||
}
|
||||
/// <value>The default is 1000 milliseconds.</value>
|
||||
public long DefaultPollIntervalMs { get; set; } = 1000;
|
||||
|
||||
pollInterval ??= TimeSpan.FromSeconds(1);
|
||||
if (pollInterval <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(pollInterval),
|
||||
pollInterval,
|
||||
"Poll interval must be positive.");
|
||||
}
|
||||
|
||||
if (pageSize <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(pageSize),
|
||||
pageSize,
|
||||
"Page size must be positive.");
|
||||
}
|
||||
|
||||
if (maxTasks is <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(maxTasks),
|
||||
maxTasks,
|
||||
"Max tasks must be positive.");
|
||||
}
|
||||
|
||||
if (maxTasksPerSession is <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(maxTasksPerSession),
|
||||
maxTasksPerSession,
|
||||
"Max tasks per session must be positive.");
|
||||
}
|
||||
|
||||
_defaultTtl = defaultTtl;
|
||||
_maxTtl = maxTtl;
|
||||
_pollInterval = pollInterval.Value;
|
||||
_pageSize = pageSize;
|
||||
_maxTasks = maxTasks;
|
||||
_maxTasksPerSession = maxTasksPerSession;
|
||||
#if MCP_TEST_TIME_PROVIDER
|
||||
_timeProvider = TimeProvider.System;
|
||||
#endif
|
||||
|
||||
cleanupInterval ??= TimeSpan.FromMinutes(1);
|
||||
if (cleanupInterval.Value != Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
#if MCP_TEST_TIME_PROVIDER
|
||||
_cleanupTimer = _timeProvider.CreateTimer(CleanupExpiredTasks, null, cleanupInterval.Value, cleanupInterval.Value);
|
||||
#else
|
||||
_cleanupTimer = new Timer(CleanupExpiredTasks, null, cleanupInterval.Value, cleanupInterval.Value);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if MCP_TEST_TIME_PROVIDER
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InMemoryMcpTaskStore"/> class with a custom time provider.
|
||||
/// This constructor is only available for testing purposes.
|
||||
/// Gets or sets the default time-to-live in milliseconds for new tasks, or <see langword="null"/> for unlimited.
|
||||
/// </summary>
|
||||
internal InMemoryMcpTaskStore(
|
||||
TimeSpan? defaultTtl,
|
||||
TimeSpan? maxTtl,
|
||||
TimeSpan? pollInterval,
|
||||
TimeSpan? cleanupInterval,
|
||||
int pageSize,
|
||||
int? maxTasks,
|
||||
int? maxTasksPerSession,
|
||||
TimeProvider timeProvider)
|
||||
: this(defaultTtl, maxTtl, pollInterval, cleanupInterval, pageSize, maxTasks, maxTasksPerSession)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
#endif
|
||||
public long? DefaultTtlMs { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<McpTask> CreateTaskAsync(
|
||||
McpTaskMetadata taskParams,
|
||||
RequestId requestId,
|
||||
JsonRpcRequest request,
|
||||
string? sessionId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
public Task<McpTaskInfo> CreateTaskAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Check global task limit
|
||||
if (_maxTasks is { } maxTasks && _tasks.Count >= maxTasks)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Maximum number of tasks ({maxTasks}) has been reached. Cannot create new task.");
|
||||
}
|
||||
var taskId = Guid.NewGuid().ToString("N");
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Check per-session task limit
|
||||
if (_maxTasksPerSession is { } maxPerSession && sessionId is not null)
|
||||
{
|
||||
var sessionTaskCount = _tasks.Values.Count(e => e.SessionId == sessionId && !IsExpired(e));
|
||||
if (sessionTaskCount >= maxPerSession)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Maximum number of tasks per session ({maxPerSession}) has been reached for session '{sessionId}'. Cannot create new task.");
|
||||
}
|
||||
}
|
||||
var info = new McpTaskInfo(taskId, McpTaskStatus.Working, now, now, DefaultTtlMs, DefaultPollIntervalMs);
|
||||
_tasks[taskId] = info;
|
||||
|
||||
var taskId = GenerateTaskId();
|
||||
var now = GetUtcNow();
|
||||
|
||||
// Determine TTL: use requested, fall back to default, respect max limit
|
||||
var ttl = taskParams.TimeToLive ?? _defaultTtl;
|
||||
if (ttl is { } ttlValue && _maxTtl is { } maxTtlValue && ttlValue > maxTtlValue)
|
||||
{
|
||||
ttl = maxTtlValue;
|
||||
}
|
||||
|
||||
TaskEntry entry = new()
|
||||
{
|
||||
TaskId = taskId,
|
||||
Status = McpTaskStatus.Working,
|
||||
CreatedAt = now,
|
||||
LastUpdatedAt = now,
|
||||
TimeToLive = ttl,
|
||||
PollInterval = _pollInterval,
|
||||
RequestId = requestId,
|
||||
Request = request,
|
||||
SessionId = sessionId
|
||||
};
|
||||
|
||||
if (!_tasks.TryAdd(taskId, entry))
|
||||
{
|
||||
// This should be extremely rare with GUID-based IDs
|
||||
throw new InvalidOperationException($"Task ID collision: {taskId}");
|
||||
}
|
||||
|
||||
return Task.FromResult(entry.ToMcpTask());
|
||||
return Task.FromResult(info);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<McpTask?> GetTaskAsync(string taskId, string? sessionId = null, CancellationToken cancellationToken = default)
|
||||
public Task<McpTaskInfo?> GetTaskAsync(string taskId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_tasks.TryGetValue(taskId, out var entry))
|
||||
{
|
||||
return Task.FromResult<McpTask?>(null);
|
||||
}
|
||||
|
||||
// Enforce session isolation if sessionId is provided
|
||||
if (sessionId != null && entry.SessionId != sessionId)
|
||||
{
|
||||
return Task.FromResult<McpTask?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<McpTask?>(entry.ToMcpTask());
|
||||
_tasks.TryGetValue(taskId, out var info);
|
||||
return Task.FromResult<McpTaskInfo?>(info);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<McpTask> StoreTaskResultAsync(
|
||||
string taskId,
|
||||
McpTaskStatus status,
|
||||
JsonElement result,
|
||||
string? sessionId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
public Task SetCompletedAsync(string taskId, JsonElement result, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (status is not (McpTaskStatus.Completed or McpTaskStatus.Failed))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Status must be {nameof(McpTaskStatus.Completed)} or {nameof(McpTaskStatus.Failed)}.",
|
||||
nameof(status));
|
||||
}
|
||||
|
||||
// Retry loop for optimistic concurrency
|
||||
while (true)
|
||||
{
|
||||
if (!_tasks.TryGetValue(taskId, out var entry))
|
||||
Update(taskId, entry => IsTerminal(entry.Status)
|
||||
? entry
|
||||
: entry with
|
||||
{
|
||||
throw new InvalidOperationException($"Task not found: {taskId}");
|
||||
}
|
||||
Status = McpTaskStatus.Completed,
|
||||
Result = result,
|
||||
LastUpdatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
|
||||
// Enforce session isolation
|
||||
if (sessionId != null && entry.SessionId != sessionId)
|
||||
{
|
||||
throw new InvalidOperationException($"Task not found: {taskId}");
|
||||
}
|
||||
|
||||
// Prevent overwriting terminal state
|
||||
if (IsTerminalStatus(entry.Status))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot store result for task in terminal state: {entry.Status}");
|
||||
}
|
||||
|
||||
var updatedEntry = new TaskEntry(entry)
|
||||
{
|
||||
Status = status,
|
||||
LastUpdatedAt = GetUtcNow(),
|
||||
StoredResult = result
|
||||
};
|
||||
|
||||
if (_tasks.TryUpdate(taskId, updatedEntry, entry))
|
||||
{
|
||||
return Task.FromResult(updatedEntry.ToMcpTask());
|
||||
}
|
||||
|
||||
// Entry was modified by another thread, retry
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<JsonElement> GetTaskResultAsync(string taskId, string? sessionId = null, CancellationToken cancellationToken = default)
|
||||
public Task SetFailedAsync(string taskId, JsonElement error, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_tasks.TryGetValue(taskId, out var entry))
|
||||
{
|
||||
throw new InvalidOperationException($"Task not found: {taskId}");
|
||||
}
|
||||
Update(taskId, entry => IsTerminal(entry.Status)
|
||||
? entry
|
||||
: entry with
|
||||
{
|
||||
Status = McpTaskStatus.Failed,
|
||||
Error = error,
|
||||
LastUpdatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
|
||||
// Enforce session isolation
|
||||
if (sessionId != entry.SessionId)
|
||||
{
|
||||
throw new InvalidOperationException($"Invalid sessionId: {sessionId} provided for {taskId}");
|
||||
}
|
||||
|
||||
if (entry.StoredResult is not { } storedResult)
|
||||
{
|
||||
throw new InvalidOperationException($"No result stored for task: {taskId}");
|
||||
}
|
||||
|
||||
return Task.FromResult(storedResult);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<McpTask> UpdateTaskStatusAsync(
|
||||
string taskId,
|
||||
McpTaskStatus status,
|
||||
string? statusMessage,
|
||||
string? sessionId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
public Task<bool> SetCancelledAsync(string taskId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Retry loop for optimistic concurrency
|
||||
while (true)
|
||||
if (!_tasks.TryGetValue(taskId, out var entry) || IsTerminal(entry.Status))
|
||||
{
|
||||
if (!_tasks.TryGetValue(taskId, out var entry))
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
bool transitioned = false;
|
||||
Update(taskId, e =>
|
||||
{
|
||||
if (IsTerminal(e.Status))
|
||||
{
|
||||
throw new InvalidOperationException($"Task not found: {taskId}");
|
||||
return e;
|
||||
}
|
||||
|
||||
// Enforce session isolation
|
||||
if (sessionId != null && entry.SessionId != sessionId)
|
||||
{
|
||||
throw new InvalidOperationException($"Task not found: {taskId}");
|
||||
}
|
||||
|
||||
var updatedEntry = new TaskEntry(entry)
|
||||
{
|
||||
Status = status,
|
||||
StatusMessage = statusMessage,
|
||||
LastUpdatedAt = GetUtcNow(),
|
||||
};
|
||||
|
||||
if (_tasks.TryUpdate(taskId, updatedEntry, entry))
|
||||
{
|
||||
return Task.FromResult(updatedEntry.ToMcpTask());
|
||||
}
|
||||
|
||||
// Entry was modified by another thread, retry
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<ListTasksResult> ListTasksAsync(
|
||||
string? cursor = null,
|
||||
string? sessionId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Stream enumeration - filter by session, exclude expired, apply keyset pagination
|
||||
var query = _tasks.Values
|
||||
.Where(e => sessionId == null || e.SessionId == sessionId)
|
||||
.Where(e => !IsExpired(e));
|
||||
|
||||
// Apply keyset filter if cursor provided: TaskId > cursor
|
||||
// UUID v7 task IDs are monotonically increasing and inherently time-ordered
|
||||
if (cursor != null)
|
||||
{
|
||||
query = query.Where(e => string.CompareOrdinal(e.TaskId, cursor) > 0);
|
||||
}
|
||||
|
||||
// Order by TaskId for stable, deterministic pagination
|
||||
// UUID v7 task IDs sort chronologically due to embedded timestamp
|
||||
var page = query
|
||||
.OrderBy(e => e.TaskId, StringComparer.Ordinal)
|
||||
.Take(_pageSize + 1) // Take one extra to check if there's a next page
|
||||
.Select(e => e.ToMcpTask())
|
||||
.ToList();
|
||||
|
||||
// Set nextCursor if we have more results
|
||||
string? nextCursor;
|
||||
if (page.Count > _pageSize)
|
||||
{
|
||||
var lastItemInPage = page[_pageSize - 1]; // Last item we'll actually return
|
||||
nextCursor = lastItemInPage.TaskId;
|
||||
page.RemoveAt(_pageSize); // Remove the extra item
|
||||
}
|
||||
else
|
||||
{
|
||||
nextCursor = null;
|
||||
}
|
||||
|
||||
return Task.FromResult(new ListTasksResult
|
||||
{
|
||||
Tasks = page.ToArray(),
|
||||
NextCursor = nextCursor
|
||||
transitioned = true;
|
||||
return e with { Status = McpTaskStatus.Cancelled, LastUpdatedAt = DateTimeOffset.UtcNow };
|
||||
});
|
||||
|
||||
return Task.FromResult(transitioned);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<McpTask> CancelTaskAsync(string taskId, string? sessionId = null, CancellationToken cancellationToken = default)
|
||||
public event Action<InputResponseReceivedEventArgs>? InputResponseReceived;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task ResolveInputRequestsAsync(
|
||||
string taskId,
|
||||
IDictionary<string, InputResponse> inputResponses,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Retry loop for optimistic concurrency
|
||||
while (true)
|
||||
bool wasTerminal = false;
|
||||
Update(taskId, entry =>
|
||||
{
|
||||
if (!_tasks.TryGetValue(taskId, out var entry))
|
||||
if (IsTerminal(entry.Status))
|
||||
{
|
||||
throw new InvalidOperationException($"Task not found: {taskId}");
|
||||
wasTerminal = true;
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Enforce session isolation
|
||||
if (sessionId != null && entry.SessionId != sessionId)
|
||||
var requests = entry.InputRequests as ImmutableDictionary<string, InputRequest>
|
||||
?? entry.InputRequests?.ToImmutableDictionary()
|
||||
?? ImmutableDictionary<string, InputRequest>.Empty;
|
||||
|
||||
foreach (var key in inputResponses.Keys)
|
||||
{
|
||||
throw new InvalidOperationException($"Task not found: {taskId}");
|
||||
requests = requests.Remove(key);
|
||||
}
|
||||
|
||||
// If already in terminal state, return unchanged
|
||||
if (IsTerminalStatus(entry.Status))
|
||||
{
|
||||
return Task.FromResult(entry.ToMcpTask());
|
||||
}
|
||||
var status = requests.IsEmpty ? McpTaskStatus.Working : entry.Status;
|
||||
|
||||
var updatedEntry = new TaskEntry(entry)
|
||||
return entry with
|
||||
{
|
||||
Status = McpTaskStatus.Cancelled,
|
||||
LastUpdatedAt = GetUtcNow(),
|
||||
InputRequests = requests,
|
||||
Status = status,
|
||||
LastUpdatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
});
|
||||
|
||||
if (_tasks.TryUpdate(taskId, updatedEntry, entry))
|
||||
if (wasTerminal)
|
||||
{
|
||||
// Drop responses targeting a terminal task — there are no listeners that can act on them.
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
foreach (var kvp in inputResponses)
|
||||
{
|
||||
InputResponseReceived?.Invoke(new InputResponseReceivedEventArgs
|
||||
{
|
||||
return Task.FromResult(updatedEntry.ToMcpTask());
|
||||
TaskId = taskId,
|
||||
RequestId = kvp.Key,
|
||||
Response = kvp.Value,
|
||||
});
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task SetInputRequestsAsync(
|
||||
string taskId,
|
||||
IDictionary<string, InputRequest> inputRequests,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Update(taskId, entry =>
|
||||
{
|
||||
if (IsTerminal(entry.Status))
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Entry was modified by another thread, retry
|
||||
}
|
||||
var requests = entry.InputRequests as ImmutableDictionary<string, InputRequest>
|
||||
?? entry.InputRequests?.ToImmutableDictionary()
|
||||
?? ImmutableDictionary<string, InputRequest>.Empty;
|
||||
|
||||
foreach (var kvp in inputRequests)
|
||||
{
|
||||
requests = requests.SetItem(kvp.Key, kvp.Value);
|
||||
}
|
||||
|
||||
return entry with
|
||||
{
|
||||
InputRequests = requests,
|
||||
Status = McpTaskStatus.InputRequired,
|
||||
LastUpdatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the task store and stops background cleanup.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_cleanupTimer?.Dispose();
|
||||
}
|
||||
|
||||
private string GenerateTaskId() =>
|
||||
IdHelpers.CreateMonotonicId(GetUtcNow());
|
||||
|
||||
private static bool IsTerminalStatus(McpTaskStatus status) =>
|
||||
private static bool IsTerminal(McpTaskStatus status) =>
|
||||
status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled;
|
||||
|
||||
#if MCP_TEST_TIME_PROVIDER
|
||||
private DateTimeOffset GetUtcNow() => _timeProvider.GetUtcNow();
|
||||
#else
|
||||
private static DateTimeOffset GetUtcNow() => DateTimeOffset.UtcNow;
|
||||
#endif
|
||||
|
||||
#if MCP_TEST_TIME_PROVIDER
|
||||
private bool IsExpired(TaskEntry entry)
|
||||
#else
|
||||
private static bool IsExpired(TaskEntry entry)
|
||||
#endif
|
||||
private void Update(string taskId, Func<McpTaskInfo, McpTaskInfo> transform)
|
||||
{
|
||||
if (entry.TimeToLive == null)
|
||||
SpinWait spin = default;
|
||||
while (true)
|
||||
{
|
||||
return false; // Unlimited lifetime
|
||||
if (!_tasks.TryGetValue(taskId, out var current))
|
||||
{
|
||||
throw new InvalidOperationException($"Task '{taskId}' not found.");
|
||||
}
|
||||
|
||||
var updated = transform(current);
|
||||
if (ReferenceEquals(updated, current) || _tasks.TryUpdate(taskId, updated, current))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
spin.SpinOnce();
|
||||
}
|
||||
|
||||
var expirationTime = entry.CreatedAt + entry.TimeToLive.Value;
|
||||
return GetUtcNow() >= expirationTime;
|
||||
}
|
||||
|
||||
private void CleanupExpiredTasks(object? state)
|
||||
{
|
||||
var expiredTaskIds = _tasks
|
||||
.Where(kvp => IsExpired(kvp.Value))
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var taskId in expiredTaskIds)
|
||||
{
|
||||
_tasks.TryRemove(taskId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TaskEntry
|
||||
{
|
||||
// Flattened McpTask properties
|
||||
public required string TaskId { get; init; }
|
||||
public required McpTaskStatus Status { get; init; }
|
||||
public string? StatusMessage { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public required DateTimeOffset LastUpdatedAt { get; init; }
|
||||
public TimeSpan? TimeToLive { get; init; }
|
||||
public TimeSpan? PollInterval { get; init; }
|
||||
|
||||
// Request metadata
|
||||
public required RequestId RequestId { get; init; }
|
||||
public required JsonRpcRequest Request { get; init; }
|
||||
public required string? SessionId { get; init; }
|
||||
public JsonElement? StoredResult { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Copy constructor for creating modified copies.
|
||||
/// </summary>
|
||||
[SetsRequiredMembers]
|
||||
public TaskEntry(TaskEntry source)
|
||||
{
|
||||
TaskId = source.TaskId;
|
||||
Status = source.Status;
|
||||
StatusMessage = source.StatusMessage;
|
||||
CreatedAt = source.CreatedAt;
|
||||
LastUpdatedAt = source.LastUpdatedAt;
|
||||
TimeToLive = source.TimeToLive;
|
||||
PollInterval = source.PollInterval;
|
||||
RequestId = source.RequestId;
|
||||
Request = source.Request;
|
||||
SessionId = source.SessionId;
|
||||
StoredResult = source.StoredResult;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default constructor for initial creation.
|
||||
/// </summary>
|
||||
public TaskEntry() { }
|
||||
|
||||
/// <summary>
|
||||
/// Converts this entry back to an McpTask for external consumption.
|
||||
/// </summary>
|
||||
public McpTask ToMcpTask() => new()
|
||||
{
|
||||
TaskId = TaskId,
|
||||
Status = Status,
|
||||
StatusMessage = StatusMessage,
|
||||
CreatedAt = CreatedAt,
|
||||
LastUpdatedAt = LastUpdatedAt,
|
||||
TimeToLive = TimeToLive,
|
||||
PollInterval = PollInterval
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace ModelContextProtocol.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Provides data for the <see cref="IMcpTaskStore.InputResponseReceived"/> event.
|
||||
/// </summary>
|
||||
[Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)]
|
||||
public sealed class InputResponseReceivedEventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the task identifier.
|
||||
/// </summary>
|
||||
public required string TaskId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the request identifier that was resolved.
|
||||
/// </summary>
|
||||
public required string RequestId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the response payload.
|
||||
/// </summary>
|
||||
public required InputResponse Response { get; init; }
|
||||
}
|
||||
|
|
@ -36,9 +36,15 @@ public sealed class McpRequestFilters
|
|||
/// Gets or sets the filters for the call-tool handler pipeline.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// These filters wrap handlers that are invoked when a client makes a call to a tool that isn't found in the <see cref="McpServerTool"/> collection.
|
||||
/// The filters can modify, log, or perform additional operations on requests and responses for
|
||||
/// <see cref="RequestMethods.ToolsCall"/> requests. The handler should implement logic to execute the requested tool and return appropriate results.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Cannot be used together with <see cref="CallToolWithTaskFilters"/>. If both are non-empty at configuration time,
|
||||
/// an <see cref="InvalidOperationException"/> will be thrown.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public IList<McpRequestFilter<CallToolRequestParams, CallToolResult>> CallToolFilters
|
||||
{
|
||||
|
|
@ -50,6 +56,31 @@ public sealed class McpRequestFilters
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the filters for the call-tool handler pipeline with task support.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// These filters wrap the task-augmented call-tool handler whose return type is
|
||||
/// <see cref="ResultOrCreatedTask{TResult}"/>. Use these filters when the server's tool pipeline
|
||||
/// supports returning either an immediate <see cref="CallToolResult"/> or a <see cref="CreateTaskResult"/>
|
||||
/// for asynchronous execution.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Cannot be used together with <see cref="CallToolFilters"/>. If both are non-empty at configuration time,
|
||||
/// an <see cref="InvalidOperationException"/> will be thrown.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public IList<McpRequestFilter<CallToolRequestParams, ResultOrCreatedTask<CallToolResult>>> CallToolWithTaskFilters
|
||||
{
|
||||
get => field ??= [];
|
||||
set
|
||||
{
|
||||
Throw.IfNull(value);
|
||||
field = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the filters for the list-prompts handler pipeline.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -67,58 +67,33 @@ public abstract partial class McpServer : McpSession
|
|||
/// then returns to <see cref="McpTaskStatus.Working"/> when the response is received.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async ValueTask<CreateMessageResult> SampleAsync(
|
||||
public ValueTask<CreateMessageResult> SampleAsync(
|
||||
CreateMessageRequestParams requestParams,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(requestParams);
|
||||
|
||||
// If executing inside a background task, redirect sampling through the task store.
|
||||
// Capability checks (ThrowIfSamplingUnsupported) are intentionally skipped here because the
|
||||
// client opted into the tasks extension when submitting the originating request, and input
|
||||
// requests are delivered through the tasks/get response channel rather than as direct
|
||||
// server->client requests. See SendRequestViaTaskAsync remarks.
|
||||
if (McpTaskExecutionContext.Current.Value is { } taskContext)
|
||||
{
|
||||
return SendRequestViaTaskAsync(taskContext, RequestMethods.SamplingCreateMessage, requestParams,
|
||||
McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.CreateMessageResult,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
ThrowIfSamplingUnsupported();
|
||||
|
||||
return await SendRequestWithTaskStatusTrackingAsync(
|
||||
return SendRequestAsync(
|
||||
RequestMethods.SamplingCreateMessage,
|
||||
requestParams,
|
||||
McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.CreateMessageResult,
|
||||
"Waiting for sampling response",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requests to sample an LLM via the client as a task, allowing the server to poll for completion.
|
||||
/// </summary>
|
||||
/// <param name="requestParams">The parameters for the sampling request.</param>
|
||||
/// <param name="taskMetadata">The task metadata specifying TTL and other task-related options.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>An <see cref="McpTask"/> representing the created task on the client.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="requestParams"/> or <paramref name="taskMetadata"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">The client does not support sampling or task-augmented sampling.</exception>
|
||||
/// <exception cref="McpException">The request failed or the client returned an error response.</exception>
|
||||
/// <remarks>
|
||||
/// Use <see cref="GetTaskAsync"/> to poll for task status and <see cref="GetTaskResultAsync{TResult}"/>
|
||||
/// (with <see cref="CreateMessageResult"/>) to retrieve the final result when the task completes.
|
||||
/// </remarks>
|
||||
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
|
||||
public async ValueTask<McpTask> SampleAsTaskAsync(
|
||||
CreateMessageRequestParams requestParams,
|
||||
McpTaskMetadata taskMetadata,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(requestParams);
|
||||
Throw.IfNull(taskMetadata);
|
||||
ThrowIfSamplingUnsupported();
|
||||
ThrowIfTasksUnsupportedForSampling();
|
||||
|
||||
// Set the task metadata on the request
|
||||
requestParams.Task = taskMetadata;
|
||||
|
||||
var result = await SendRequestAsync(
|
||||
RequestMethods.SamplingCreateMessage,
|
||||
requestParams,
|
||||
McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.CreateTaskResult,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return result.Task;
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -301,6 +276,20 @@ public abstract partial class McpServer : McpSession
|
|||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(requestParams);
|
||||
|
||||
// If executing inside a background task, redirect through the task store.
|
||||
// Capability checks (ThrowIfRootsUnsupported) are intentionally skipped here because the
|
||||
// client opted into the tasks extension when submitting the originating request, and input
|
||||
// requests are delivered through the tasks/get response channel rather than as direct
|
||||
// server->client requests. See SendRequestViaTaskAsync remarks.
|
||||
if (McpTaskExecutionContext.Current.Value is { } taskContext)
|
||||
{
|
||||
return SendRequestViaTaskAsync(taskContext, RequestMethods.RootsList, requestParams,
|
||||
McpJsonUtilities.JsonContext.Default.ListRootsRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.ListRootsResult,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
ThrowIfRootsUnsupported();
|
||||
|
||||
return SendRequestAsync(
|
||||
|
|
@ -339,352 +328,33 @@ public abstract partial class McpServer : McpSession
|
|||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(requestParams);
|
||||
|
||||
// If executing inside a background task, redirect elicitation through the task store.
|
||||
// Capability checks (ThrowIfElicitationUnsupported) are intentionally skipped here because
|
||||
// the client opted into the tasks extension when submitting the originating request, and
|
||||
// input requests are delivered through the tasks/get response channel rather than as
|
||||
// direct server->client requests. See SendRequestViaTaskAsync remarks.
|
||||
if (McpTaskExecutionContext.Current.Value is { } taskContext)
|
||||
{
|
||||
var taskResult = await SendRequestViaTaskAsync(taskContext, RequestMethods.ElicitationCreate, requestParams,
|
||||
McpJsonUtilities.JsonContext.Default.ElicitRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.ElicitResult,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return taskResult ?? new ElicitResult { Action = "cancel" };
|
||||
}
|
||||
|
||||
ThrowIfElicitationUnsupported(requestParams);
|
||||
|
||||
var result = await SendRequestWithTaskStatusTrackingAsync(
|
||||
var result = await SendRequestAsync(
|
||||
RequestMethods.ElicitationCreate,
|
||||
requestParams,
|
||||
McpJsonUtilities.JsonContext.Default.ElicitRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.ElicitResult,
|
||||
"Waiting for user input",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return ElicitResult.WithDefaults(requestParams, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requests additional information from the user via the client as a task, allowing the server to poll for completion.
|
||||
/// </summary>
|
||||
/// <param name="requestParams">The parameters for the elicitation request.</param>
|
||||
/// <param name="taskMetadata">The task metadata specifying TTL and other task-related options.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>An <see cref="McpTask"/> representing the created task on the client.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="requestParams"/> or <paramref name="taskMetadata"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">The client does not support elicitation or task-augmented elicitation.</exception>
|
||||
/// <exception cref="McpException">The request failed or the client returned an error response.</exception>
|
||||
/// <remarks>
|
||||
/// Use <see cref="GetTaskAsync"/> to poll for task status and <see cref="GetTaskResultAsync{TResult}"/>
|
||||
/// (with <see cref="ElicitResult"/>) to retrieve the final result when the task completes.
|
||||
/// </remarks>
|
||||
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
|
||||
public async ValueTask<McpTask> ElicitAsTaskAsync(
|
||||
ElicitRequestParams requestParams,
|
||||
McpTaskMetadata taskMetadata,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(requestParams);
|
||||
Throw.IfNull(taskMetadata);
|
||||
ThrowIfElicitationUnsupported(requestParams);
|
||||
ThrowIfTasksUnsupportedForElicitation();
|
||||
|
||||
// Set the task metadata on the request
|
||||
requestParams.Task = taskMetadata;
|
||||
|
||||
var result = await SendRequestAsync(
|
||||
RequestMethods.ElicitationCreate,
|
||||
requestParams,
|
||||
McpJsonUtilities.JsonContext.Default.ElicitRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.CreateTaskResult,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return result.Task;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the current state of a specific task from the client.
|
||||
/// </summary>
|
||||
/// <param name="taskId">The unique identifier of the task to retrieve.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>The current state of the task.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="taskId"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentException"><paramref name="taskId"/> is empty or composed entirely of whitespace.</exception>
|
||||
/// <exception cref="InvalidOperationException">The client does not support tasks.</exception>
|
||||
/// <exception cref="McpException">The request failed or the client returned an error response.</exception>
|
||||
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
|
||||
public async ValueTask<McpTask> GetTaskAsync(
|
||||
string taskId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNullOrWhiteSpace(taskId);
|
||||
ThrowIfTasksUnsupported();
|
||||
|
||||
var result = await SendRequestAsync(
|
||||
RequestMethods.TasksGet,
|
||||
new GetTaskRequestParams { TaskId = taskId },
|
||||
McpJsonUtilities.JsonContext.Default.GetTaskRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.GetTaskResult,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Convert GetTaskResult to McpTask
|
||||
return new McpTask
|
||||
{
|
||||
TaskId = result.TaskId,
|
||||
Status = result.Status,
|
||||
StatusMessage = result.StatusMessage,
|
||||
CreatedAt = result.CreatedAt,
|
||||
LastUpdatedAt = result.LastUpdatedAt,
|
||||
TimeToLive = result.TimeToLive,
|
||||
PollInterval = result.PollInterval
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the result of a completed task from the client, blocking until the task reaches a terminal state.
|
||||
/// </summary>
|
||||
/// <typeparam name="TResult">The type to deserialize the task result into.</typeparam>
|
||||
/// <param name="taskId">The unique identifier of the task whose result to retrieve.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional serializer options for deserializing the result.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>The result of the task, deserialized into type <typeparamref name="TResult"/>.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="taskId"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentException"><paramref name="taskId"/> is empty or composed entirely of whitespace.</exception>
|
||||
/// <exception cref="InvalidOperationException">The client does not support tasks.</exception>
|
||||
/// <exception cref="McpException">The request failed or the client returned an error response.</exception>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This method sends a tasks/result request to the client, which will block until the task completes if it hasn't already.
|
||||
/// The client handles all polling logic internally.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// For sampling tasks, use <see cref="CreateMessageResult"/> as <typeparamref name="TResult"/>.
|
||||
/// For elicitation tasks, use <see cref="ElicitResult"/> as <typeparamref name="TResult"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
|
||||
public async ValueTask<TResult?> GetTaskResultAsync<TResult>(
|
||||
string taskId,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNullOrWhiteSpace(taskId);
|
||||
ThrowIfTasksUnsupported();
|
||||
|
||||
var result = await SendRequestAsync(
|
||||
RequestMethods.TasksResult,
|
||||
new GetTaskPayloadRequestParams { TaskId = taskId },
|
||||
McpJsonUtilities.JsonContext.Default.GetTaskPayloadRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.JsonElement,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var serializerOptions = jsonSerializerOptions ?? McpJsonUtilities.DefaultOptions;
|
||||
serializerOptions.MakeReadOnly();
|
||||
|
||||
var typeInfo = serializerOptions.GetTypeInfo<TResult>();
|
||||
return result.Deserialize(typeInfo);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a list of all tasks from the client.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A list of all tasks.</returns>
|
||||
/// <exception cref="InvalidOperationException">The client does not support tasks or task listing.</exception>
|
||||
/// <exception cref="McpException">The request failed or the client returned an error response.</exception>
|
||||
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
|
||||
public async ValueTask<IList<McpTask>> ListTasksAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfTasksUnsupported();
|
||||
ThrowIfTaskListingUnsupported();
|
||||
|
||||
List<McpTask>? tasks = null;
|
||||
ListTasksRequestParams requestParams = new();
|
||||
do
|
||||
{
|
||||
var taskResults = await ListTasksAsync(requestParams, cancellationToken).ConfigureAwait(false);
|
||||
if (tasks is null)
|
||||
{
|
||||
tasks = new List<McpTask>(taskResults.Tasks.Count);
|
||||
}
|
||||
|
||||
foreach (var mcpTask in taskResults.Tasks)
|
||||
{
|
||||
tasks.Add(mcpTask);
|
||||
}
|
||||
|
||||
requestParams.Cursor = taskResults.NextCursor;
|
||||
}
|
||||
while (requestParams.Cursor is not null);
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a list of tasks from the client.
|
||||
/// </summary>
|
||||
/// <param name="requestParams">The request parameters to send in the request.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>The result of the request as provided by the client.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="requestParams"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">The client does not support tasks or task listing.</exception>
|
||||
/// <exception cref="McpException">The request failed or the client returned an error response.</exception>
|
||||
/// <remarks>
|
||||
/// The <see cref="ListTasksAsync(CancellationToken)"/> overload retrieves all tasks by automatically handling pagination.
|
||||
/// This overload works with the lower-level <see cref="ListTasksRequestParams"/> and <see cref="ListTasksResult"/>, returning the raw result from the client.
|
||||
/// Any pagination needs to be managed by the caller.
|
||||
/// </remarks>
|
||||
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
|
||||
public ValueTask<ListTasksResult> ListTasksAsync(
|
||||
ListTasksRequestParams requestParams,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(requestParams);
|
||||
ThrowIfTasksUnsupported();
|
||||
ThrowIfTaskListingUnsupported();
|
||||
|
||||
return SendRequestAsync(
|
||||
RequestMethods.TasksList,
|
||||
requestParams,
|
||||
McpJsonUtilities.JsonContext.Default.ListTasksRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.ListTasksResult,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a running task on the client.
|
||||
/// </summary>
|
||||
/// <param name="taskId">The unique identifier of the task to cancel.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>The updated state of the task after cancellation.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="taskId"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentException"><paramref name="taskId"/> is empty or composed entirely of whitespace.</exception>
|
||||
/// <exception cref="InvalidOperationException">The client does not support tasks or task cancellation.</exception>
|
||||
/// <exception cref="McpException">The request failed or the client returned an error response.</exception>
|
||||
/// <remarks>
|
||||
/// Cancelling a task requests that the client stop execution. The client may not immediately cancel the task,
|
||||
/// and may choose to allow the task to complete if it's close to finishing.
|
||||
/// </remarks>
|
||||
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
|
||||
public async ValueTask<McpTask> CancelTaskAsync(
|
||||
string taskId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNullOrWhiteSpace(taskId);
|
||||
ThrowIfTasksUnsupported();
|
||||
ThrowIfTaskCancellationUnsupported();
|
||||
|
||||
var result = await SendRequestAsync(
|
||||
RequestMethods.TasksCancel,
|
||||
new CancelMcpTaskRequestParams { TaskId = taskId },
|
||||
McpJsonUtilities.JsonContext.Default.CancelMcpTaskRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.CancelMcpTaskResult,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Convert CancelMcpTaskResult to McpTask
|
||||
return new McpTask
|
||||
{
|
||||
TaskId = result.TaskId,
|
||||
Status = result.Status,
|
||||
StatusMessage = result.StatusMessage,
|
||||
CreatedAt = result.CreatedAt,
|
||||
LastUpdatedAt = result.LastUpdatedAt,
|
||||
TimeToLive = result.TimeToLive,
|
||||
PollInterval = result.PollInterval
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Polls a task on the client until it reaches a terminal state.
|
||||
/// </summary>
|
||||
/// <param name="taskId">The unique identifier of the task to poll.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>The task in its terminal state.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="taskId"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentException"><paramref name="taskId"/> is empty or composed entirely of whitespace.</exception>
|
||||
/// <exception cref="InvalidOperationException">The client does not support tasks.</exception>
|
||||
/// <exception cref="McpException">The request failed or the client returned an error response.</exception>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This method repeatedly calls <see cref="GetTaskAsync"/> until the task reaches a terminal status.
|
||||
/// It respects the <see cref="McpTask.PollInterval"/> returned by the client to determine how long
|
||||
/// to wait between polling attempts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// For retrieving the actual result of a completed task, use <see cref="GetTaskResultAsync{TResult}"/>
|
||||
/// or <see cref="WaitForTaskResultAsync{TResult}"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
|
||||
public async ValueTask<McpTask> PollTaskUntilCompleteAsync(
|
||||
string taskId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNullOrWhiteSpace(taskId);
|
||||
|
||||
McpTask task;
|
||||
do
|
||||
{
|
||||
task = await GetTaskAsync(taskId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// If task is in a terminal state, we're done
|
||||
if (task.Status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Wait for the poll interval before checking again (default to 1 second)
|
||||
var pollInterval = task.PollInterval ?? TimeSpan.FromSeconds(1);
|
||||
await Task.Delay(pollInterval, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
while (true);
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for a task on the client to complete and retrieves its result.
|
||||
/// </summary>
|
||||
/// <typeparam name="TResult">The type to deserialize the task result into.</typeparam>
|
||||
/// <param name="taskId">The unique identifier of the task whose result to retrieve.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional serializer options for deserializing the result.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A tuple containing the final task state and its result.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="taskId"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentException"><paramref name="taskId"/> is empty or composed entirely of whitespace.</exception>
|
||||
/// <exception cref="InvalidOperationException">The client does not support tasks.</exception>
|
||||
/// <exception cref="McpException">The task failed or was cancelled.</exception>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This method combines <see cref="PollTaskUntilCompleteAsync"/> and <see cref="GetTaskResultAsync{TResult}"/>
|
||||
/// to provide a convenient way to wait for a task to complete and retrieve its result in a single call.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If the task completes with a status of <see cref="McpTaskStatus.Failed"/> or <see cref="McpTaskStatus.Cancelled"/>,
|
||||
/// an <see cref="McpException"/> is thrown.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// For sampling tasks, use <see cref="CreateMessageResult"/> as <typeparamref name="TResult"/>.
|
||||
/// For elicitation tasks, use <see cref="ElicitResult"/> as <typeparamref name="TResult"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
|
||||
public async ValueTask<(McpTask Task, TResult? Result)> WaitForTaskResultAsync<TResult>(
|
||||
string taskId,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNullOrWhiteSpace(taskId);
|
||||
|
||||
// Poll until task reaches terminal state
|
||||
var task = await PollTaskUntilCompleteAsync(taskId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Check for failure or cancellation
|
||||
if (task.Status == McpTaskStatus.Failed)
|
||||
{
|
||||
throw new McpException($"Task '{taskId}' failed: {task.StatusMessage ?? "Unknown error"}");
|
||||
}
|
||||
|
||||
if (task.Status == McpTaskStatus.Cancelled)
|
||||
{
|
||||
throw new McpException($"Task '{taskId}' was cancelled");
|
||||
}
|
||||
|
||||
// Retrieve the result
|
||||
var result = await GetTaskResultAsync<TResult>(taskId, jsonSerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return (task, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requests additional information from the user via the client, constructing a request schema from the
|
||||
/// public serializable properties of <typeparamref name="T"/> and deserializing the response into <typeparamref name="T"/>.
|
||||
|
|
@ -747,6 +417,26 @@ public abstract partial class McpServer : McpSession
|
|||
return new ElicitResult<T> { Action = raw.Action, Content = typed };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a task status notification to the connected client.
|
||||
/// </summary>
|
||||
/// <param name="notificationParams">The task status notification parameters to send.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task that represents the asynchronous send operation.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="notificationParams"/> is <see langword="null"/>.</exception>
|
||||
public Task SendTaskStatusNotificationAsync(
|
||||
TaskStatusNotificationParams notificationParams,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(notificationParams);
|
||||
|
||||
return SendNotificationAsync(
|
||||
NotificationMethods.TaskStatusNotification,
|
||||
notificationParams,
|
||||
McpJsonUtilities.JsonContext.Default.TaskStatusNotificationParams,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a request schema for elicitation based on the public serializable properties of <paramref name="type"/>.
|
||||
/// </summary>
|
||||
|
|
@ -896,6 +586,88 @@ public abstract partial class McpServer : McpSession
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a scope that redirects server-initiated requests (elicitation, sampling, list roots) through
|
||||
/// the task store as input requests for the duration of the scope. Use this when executing tool logic
|
||||
/// in the background as a task, so that any server-to-client requests are surfaced to the client via
|
||||
/// the task's <see cref="McpTaskStatus.InputRequired"/> state instead of direct JSON-RPC messages.
|
||||
/// </summary>
|
||||
/// <param name="taskId">The task ID in the store.</param>
|
||||
/// <param name="store">The task store to write input requests to.</param>
|
||||
/// <returns>An <see cref="IDisposable"/> that restores the previous context when disposed.</returns>
|
||||
[Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)]
|
||||
public IDisposable CreateMcpTaskScope(
|
||||
string taskId,
|
||||
IMcpTaskStore store)
|
||||
{
|
||||
Throw.IfNull(taskId);
|
||||
Throw.IfNull(store);
|
||||
|
||||
var previous = McpTaskExecutionContext.Current.Value;
|
||||
McpTaskExecutionContext.Current.Value = new McpTaskExecutionContext
|
||||
{
|
||||
TaskId = taskId,
|
||||
Store = store,
|
||||
};
|
||||
return new McpTaskExecutionContext.Scope(previous);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a server-initiated request through the task store as an input request, then awaits the response.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When executing inside a task scope, capability negotiation checks (such as
|
||||
/// <see cref="ThrowIfSamplingUnsupported"/>, <see cref="ThrowIfRootsUnsupported"/>, and
|
||||
/// <see cref="ThrowIfElicitationUnsupported"/>) are intentionally skipped by the callers
|
||||
/// of this helper. The task channel itself is the negotiated capability: the client opted
|
||||
/// in to the tasks extension when it submitted the originating request, and is responsible
|
||||
/// for handling or rejecting the input requests surfaced through <c>tasks/get</c>.
|
||||
/// </remarks>
|
||||
private async ValueTask<TResponse> SendRequestViaTaskAsync<TRequest, TResponse>(
|
||||
McpTaskExecutionContext taskContext,
|
||||
string method,
|
||||
TRequest request,
|
||||
JsonTypeInfo<TRequest> requestTypeInfo,
|
||||
JsonTypeInfo<TResponse> responseTypeInfo,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var requestId = Guid.NewGuid().ToString("N");
|
||||
var paramsJson = JsonSerializer.SerializeToElement(request, requestTypeInfo);
|
||||
|
||||
var inputRequest = new InputRequest
|
||||
{
|
||||
Method = method,
|
||||
Params = paramsJson,
|
||||
};
|
||||
|
||||
var tcs = new TaskCompletionSource<InputResponse>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
void handler(InputResponseReceivedEventArgs args)
|
||||
{
|
||||
if (args.TaskId == taskContext.TaskId && args.RequestId == requestId)
|
||||
{
|
||||
tcs.TrySetResult(args.Response);
|
||||
}
|
||||
}
|
||||
|
||||
taskContext.Store.InputResponseReceived += handler;
|
||||
try
|
||||
{
|
||||
await taskContext.Store.SetInputRequestsAsync(
|
||||
taskContext.TaskId,
|
||||
new Dictionary<string, InputRequest> { [requestId] = inputRequest },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return response.Deserialize(responseTypeInfo)!;
|
||||
}
|
||||
finally
|
||||
{
|
||||
taskContext.Store.InputResponseReceived -= handler;
|
||||
}
|
||||
}
|
||||
|
||||
private void ThrowIfElicitationUnsupported(ElicitRequestParams request)
|
||||
{
|
||||
if (ClientCapabilities is null)
|
||||
|
|
@ -940,120 +712,6 @@ public abstract partial class McpServer : McpSession
|
|||
}
|
||||
}
|
||||
|
||||
private void ThrowIfTasksUnsupportedForSampling()
|
||||
{
|
||||
if (ClientCapabilities?.Tasks?.Requests?.Sampling?.CreateMessage is null)
|
||||
{
|
||||
if (ClientCapabilities is null)
|
||||
{
|
||||
throw new InvalidOperationException("Task-augmented sampling is not supported in stateless mode.");
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Client does not support task-augmented sampling requests.");
|
||||
}
|
||||
}
|
||||
|
||||
private void ThrowIfTasksUnsupportedForElicitation()
|
||||
{
|
||||
if (ClientCapabilities?.Tasks?.Requests?.Elicitation?.Create is null)
|
||||
{
|
||||
if (ClientCapabilities is null)
|
||||
{
|
||||
throw new InvalidOperationException("Task-augmented elicitation is not supported in stateless mode.");
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Client does not support task-augmented elicitation requests.");
|
||||
}
|
||||
}
|
||||
|
||||
private void ThrowIfTasksUnsupported()
|
||||
{
|
||||
if (ClientCapabilities?.Tasks is null)
|
||||
{
|
||||
if (ClientCapabilities is null)
|
||||
{
|
||||
throw new InvalidOperationException("Tasks are not supported in stateless mode.");
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Client does not support tasks.");
|
||||
}
|
||||
}
|
||||
|
||||
private void ThrowIfTaskListingUnsupported()
|
||||
{
|
||||
if (ClientCapabilities?.Tasks?.List is null)
|
||||
{
|
||||
throw new InvalidOperationException("Client does not support task listing.");
|
||||
}
|
||||
}
|
||||
|
||||
private void ThrowIfTaskCancellationUnsupported()
|
||||
{
|
||||
if (ClientCapabilities?.Tasks?.Cancel is null)
|
||||
{
|
||||
throw new InvalidOperationException("Client does not support task cancellation.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a request to the client, automatically updating task status to InputRequired during
|
||||
/// the request when called within a task execution context.
|
||||
/// </summary>
|
||||
private async ValueTask<TResult> SendRequestWithTaskStatusTrackingAsync<TParams, TResult>(
|
||||
string method,
|
||||
TParams requestParams,
|
||||
JsonTypeInfo<TParams> paramsTypeInfo,
|
||||
JsonTypeInfo<TResult> resultTypeInfo,
|
||||
string inputRequiredMessage,
|
||||
CancellationToken cancellationToken)
|
||||
where TParams : RequestParams
|
||||
where TResult : notnull
|
||||
{
|
||||
var taskContext = TaskExecutionContext.Current;
|
||||
|
||||
// If we're not in a task execution context, just send the request normally
|
||||
if (taskContext is null)
|
||||
{
|
||||
return await SendRequestAsync(method, requestParams, paramsTypeInfo, resultTypeInfo, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Update task status to InputRequired
|
||||
var inputRequiredTask = await taskContext.TaskStore.UpdateTaskStatusAsync(
|
||||
taskContext.TaskId,
|
||||
Protocol.McpTaskStatus.InputRequired,
|
||||
inputRequiredMessage,
|
||||
taskContext.SessionId,
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
// Send notification if enabled
|
||||
if (taskContext.SendNotifications && taskContext.NotifyTaskStatusFunc is not null)
|
||||
{
|
||||
_ = taskContext.NotifyTaskStatusFunc(inputRequiredTask, CancellationToken.None);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Send the actual request
|
||||
return await SendRequestAsync(method, requestParams, paramsTypeInfo, resultTypeInfo, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Update task status back to Working
|
||||
var workingTask = await taskContext.TaskStore.UpdateTaskStatusAsync(
|
||||
taskContext.TaskId,
|
||||
Protocol.McpTaskStatus.Working,
|
||||
null, // Clear status message
|
||||
taskContext.SessionId,
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
// Send notification if enabled
|
||||
if (taskContext.SendNotifications && taskContext.NotifyTaskStatusFunc is not null)
|
||||
{
|
||||
_ = taskContext.NotifyTaskStatusFunc(workingTask, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Provides an <see cref="IChatClient"/> implementation that's implemented via client sampling.</summary>
|
||||
private sealed class SamplingChatClient(McpServer server, JsonSerializerOptions serializerOptions) : IChatClient
|
||||
{
|
||||
|
|
@ -1091,50 +749,6 @@ public abstract partial class McpServer : McpSession
|
|||
void IDisposable.Dispose() { } // nop
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a task status notification to the connected client.
|
||||
/// </summary>
|
||||
/// <param name="task">The task whose status changed.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>A task representing the asynchronous notification operation.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="task"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="McpException">The notification failed or the client returned an error response.</exception>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This method sends an optional status notification to inform the client of task state changes.
|
||||
/// According to the MCP specification, receivers MAY send this notification but are not required to.
|
||||
/// Clients must not rely on receiving these notifications and should continue polling via tasks/get.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The notification is sent using the standard <c>notifications/tasks/status</c> method and includes
|
||||
/// the full task state information.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
|
||||
public Task NotifyTaskStatusAsync(
|
||||
McpTask task,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(task);
|
||||
|
||||
var notificationParams = new McpTaskStatusNotificationParams
|
||||
{
|
||||
TaskId = task.TaskId,
|
||||
Status = task.Status,
|
||||
StatusMessage = task.StatusMessage,
|
||||
CreatedAt = task.CreatedAt,
|
||||
LastUpdatedAt = task.LastUpdatedAt,
|
||||
TimeToLive = task.TimeToLive,
|
||||
PollInterval = task.PollInterval
|
||||
};
|
||||
|
||||
return SendNotificationAsync(
|
||||
NotificationMethods.TaskStatusNotification,
|
||||
notificationParams,
|
||||
McpJsonUtilities.JsonContext.Default.McpTaskStatusNotificationParams,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides an <see cref="ILoggerProvider"/> implementation for creating loggers
|
||||
/// that send logging message notifications to the client for logged messages.
|
||||
|
|
|
|||
|
|
@ -42,8 +42,53 @@ public sealed class McpServerHandlers
|
|||
/// <remarks>
|
||||
/// This handler is invoked when a client makes a call to a tool that isn't found in the <see cref="McpServerTool"/> collection.
|
||||
/// The handler should implement logic to execute the requested tool and return appropriate results.
|
||||
/// Use <see cref="CallToolWithTaskHandler"/> instead if the tool may return a <see cref="CreateTaskResult"/>
|
||||
/// for asynchronous execution.
|
||||
/// </remarks>
|
||||
public McpRequestHandler<CallToolRequestParams, CallToolResult>? CallToolHandler { get; set; }
|
||||
/// <exception cref="InvalidOperationException"><see cref="CallToolWithTaskHandler"/> is already set.</exception>
|
||||
public McpRequestHandler<CallToolRequestParams, CallToolResult>? CallToolHandler
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
if (value is not null && CallToolWithTaskHandler is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot set {nameof(CallToolHandler)} when {nameof(CallToolWithTaskHandler)} is already set. Only one call tool handler may be configured.");
|
||||
}
|
||||
|
||||
field = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the handler for <see cref="RequestMethods.ToolsCall"/> requests with task support.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This handler is invoked when a client makes a call to a tool, allowing the tool to return either
|
||||
/// a <see cref="CallToolResult"/> for immediate results or a <see cref="CreateTaskResult"/> for
|
||||
/// long-running asynchronous operations.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Cannot be set if <see cref="CallToolHandler"/> is already set.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <exception cref="InvalidOperationException"><see cref="CallToolHandler"/> is already set.</exception>
|
||||
public McpRequestHandler<CallToolRequestParams, ResultOrCreatedTask<CallToolResult>>? CallToolWithTaskHandler
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
if (value is not null && CallToolHandler is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot set {nameof(CallToolWithTaskHandler)} when {nameof(CallToolHandler)} is already set. Only one call tool handler may be configured.");
|
||||
}
|
||||
|
||||
field = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the handler for <see cref="RequestMethods.PromptsList"/> requests.
|
||||
|
|
@ -156,6 +201,74 @@ public sealed class McpServerHandlers
|
|||
/// </remarks>
|
||||
public McpRequestHandler<SetLevelRequestParams, EmptyResult>? SetLoggingLevelHandler { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the handler for <see cref="RequestMethods.TasksGet"/> requests.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This handler is invoked when a client polls for the current state of a task.
|
||||
/// The handler should return the appropriate <see cref="GetTaskResult"/> subtype
|
||||
/// based on the task's current status (for example, <see cref="WorkingTaskResult"/>,
|
||||
/// <see cref="InputRequiredTaskResult"/>, <see cref="CompletedTaskResult"/>,
|
||||
/// <see cref="CancelledTaskResult"/>, or <see cref="FailedTaskResult"/>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Setting <see cref="McpServerOptions.TaskStore"/> is the recommended way to wire all three
|
||||
/// task lifecycle handlers (<see cref="GetTaskHandler"/>, <see cref="UpdateTaskHandler"/>,
|
||||
/// and <see cref="CancelTaskHandler"/>) from a single source while still allowing explicit
|
||||
/// handlers to override any slot. If <see cref="CallToolWithTaskHandler"/> can return a
|
||||
/// <see cref="CreateTaskResult"/> but no <see cref="GetTaskHandler"/> is configured (either
|
||||
/// directly or via a task store), the server throws <see cref="InvalidOperationException"/>
|
||||
/// when processing the request so misconfigured deployments fail loudly instead of producing
|
||||
/// unpollable tasks.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public McpRequestHandler<GetTaskRequestParams, GetTaskResult>? GetTaskHandler { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the handler for <see cref="RequestMethods.TasksUpdate"/> requests.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This handler is invoked when a client provides input responses for a task
|
||||
/// that is in the <see cref="McpTaskStatus.InputRequired"/> state. Responses keyed
|
||||
/// by an identifier that does not currently correspond to an outstanding input request
|
||||
/// (including responses for tasks in a terminal state) should be silently ignored per
|
||||
/// SEP-2663.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Prefer configuring <see cref="McpServerOptions.TaskStore"/> instead of setting this
|
||||
/// handler directly; the default implementation built from the store dispatches to
|
||||
/// <see cref="IMcpTaskStore.ResolveInputRequestsAsync"/> and raises
|
||||
/// <see cref="IMcpTaskStore.InputResponseReceived"/> to wake any pending
|
||||
/// <see cref="McpServer.ElicitAsync(ModelContextProtocol.Protocol.ElicitRequestParams, CancellationToken)"/>
|
||||
/// or <see cref="McpServer.SampleAsync(ModelContextProtocol.Protocol.CreateMessageRequestParams, CancellationToken)"/>
|
||||
/// calls executing inside a task scope.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public McpRequestHandler<UpdateTaskRequestParams, UpdateTaskResult>? UpdateTaskHandler { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the handler for <see cref="RequestMethods.TasksCancel"/> requests.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This handler is invoked when a client requests cancellation of an in-progress task.
|
||||
/// Per SEP-2663, cancellation is cooperative and eventually consistent: the handler should
|
||||
/// always acknowledge the request with <see cref="CancelTaskResult"/>, even if the task is
|
||||
/// unknown, already terminal, or cannot actually be stopped. Whether the task transitions
|
||||
/// to <see cref="McpTaskStatus.Cancelled"/> is up to the implementation.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Prefer configuring <see cref="McpServerOptions.TaskStore"/> instead of setting this
|
||||
/// handler directly; the default implementation built from the store calls
|
||||
/// <see cref="IMcpTaskStore.SetCancelledAsync"/> and signals the per-task
|
||||
/// <see cref="CancellationTokenSource"/> so the tool's <see cref="CancellationToken"/>
|
||||
/// observes cancellation.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public McpRequestHandler<CancelTaskRequestParams, CancelTaskResult>? CancelTaskHandler { get; set; }
|
||||
|
||||
/// <summary>Gets or sets notification handlers to register with the server.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ using System.Text.Json.Serialization.Metadata;
|
|||
namespace ModelContextProtocol.Server;
|
||||
|
||||
/// <inheritdoc />
|
||||
#pragma warning disable MCPEXP002
|
||||
#pragma warning disable MCPEXP001, MCPEXP002
|
||||
internal sealed partial class McpServerImpl : McpServer
|
||||
{
|
||||
internal static Implementation DefaultImplementation { get; } = new()
|
||||
|
|
@ -28,7 +28,7 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
private readonly RequestHandlers _requestHandlers;
|
||||
private readonly McpSessionHandler _sessionHandler;
|
||||
private readonly SemaphoreSlim _disposeLock = new(1, 1);
|
||||
private readonly McpTaskCancellationTokenProvider? _taskCancellationTokenProvider;
|
||||
private readonly ConcurrentDictionary<string, CancellationTokenSource> _taskCancellationSources = new();
|
||||
private readonly ConcurrentDictionary<string, MrtrContinuation> _mrtrContinuations = new();
|
||||
private readonly ConcurrentDictionary<RequestId, MrtrContext> _mrtrContextsByRequestId = new();
|
||||
|
||||
|
|
@ -77,12 +77,6 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
_servicesScopePerRequest = options.ScopeRequests;
|
||||
_logger = loggerFactory?.CreateLogger<McpServer>() ?? NullLogger<McpServer>.Instance;
|
||||
|
||||
// Only allocate the cancellation token provider if a task store is configured
|
||||
if (options.TaskStore is not null)
|
||||
{
|
||||
_taskCancellationTokenProvider = new McpTaskCancellationTokenProvider();
|
||||
}
|
||||
|
||||
_clientInfo = options.KnownClientInfo;
|
||||
_clientCapabilities = options.KnownClientCapabilities;
|
||||
UpdateEndpointNameWithClientInfo();
|
||||
|
|
@ -96,10 +90,10 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
ConfigureTools(options);
|
||||
ConfigurePrompts(options);
|
||||
ConfigureResources(options);
|
||||
ConfigureTasks(options);
|
||||
ConfigureLogging(options);
|
||||
ConfigureCompletion(options);
|
||||
ConfigureExperimentalAndExtensions(options);
|
||||
ConfigureTasks(options);
|
||||
ConfigureMrtr();
|
||||
|
||||
// Register any notification handlers that were provided.
|
||||
|
|
@ -220,11 +214,17 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
|
||||
_disposed = true;
|
||||
|
||||
// Dispose the session handler first - cancels message processing and waits for all
|
||||
foreach (var kvp in _taskCancellationSources)
|
||||
{
|
||||
kvp.Value.Cancel();
|
||||
kvp.Value.Dispose();
|
||||
}
|
||||
_taskCancellationSources.Clear();
|
||||
|
||||
// Dispose the session handler - cancels message processing and waits for all
|
||||
// in-flight request handlers (including retries in AwaitMrtrHandlerAsync) to complete.
|
||||
// After this returns, no new requests can be processed and no new MRTR continuations
|
||||
// can be created, so _mrtrContinuations is effectively frozen.
|
||||
_taskCancellationTokenProvider?.Dispose();
|
||||
_disposables.ForEach(d => d());
|
||||
await _sessionHandler.DisposeAsync().ConfigureAwait(false);
|
||||
|
||||
|
|
@ -431,6 +431,84 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
return result;
|
||||
}
|
||||
|
||||
private void ConfigureTasks(McpServerOptions options)
|
||||
{
|
||||
var getTaskHandler = options.Handlers.GetTaskHandler;
|
||||
var updateTaskHandler = options.Handlers.UpdateTaskHandler;
|
||||
var cancelTaskHandler = options.Handlers.CancelTaskHandler;
|
||||
var taskStore = options.TaskStore;
|
||||
|
||||
// If a task store is provided, wire up handlers from it for any that aren't explicitly set.
|
||||
if (taskStore is not null)
|
||||
{
|
||||
getTaskHandler ??= async (request, cancellationToken) =>
|
||||
{
|
||||
var info = await taskStore.GetTaskAsync(request.Params!.TaskId, cancellationToken).ConfigureAwait(false);
|
||||
return info is null
|
||||
? throw new McpProtocolException($"Unknown task: '{request.Params.TaskId}'", McpErrorCode.InvalidParams)
|
||||
: ToGetTaskResult(info);
|
||||
};
|
||||
|
||||
updateTaskHandler ??= async (request, cancellationToken) =>
|
||||
{
|
||||
var inputResponses = request.Params!.InputResponses ?? new Dictionary<string, InputResponse>();
|
||||
await taskStore.ResolveInputRequestsAsync(request.Params.TaskId, inputResponses, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new UpdateTaskResult();
|
||||
};
|
||||
|
||||
cancelTaskHandler ??= async (request, cancellationToken) =>
|
||||
{
|
||||
// Idempotent ack per SEP-2663: always return CancelTaskResult regardless of whether
|
||||
// the task was known/cancellable. The store's SetCancelledAsync no-ops for unknown
|
||||
// or already-terminal tasks; we still surface a success response to the client.
|
||||
await taskStore.SetCancelledAsync(request.Params!.TaskId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Signal the task's CancellationTokenSource if one exists. Whichever side
|
||||
// (this handler or the background runner's finally block) wins TryRemove owns disposal,
|
||||
// which prevents the runner from observing ObjectDisposedException through cts.Token.
|
||||
if (_taskCancellationSources.TryRemove(request.Params.TaskId, out var cts))
|
||||
{
|
||||
cts.Cancel();
|
||||
cts.Dispose();
|
||||
}
|
||||
|
||||
return new CancelTaskResult();
|
||||
};
|
||||
}
|
||||
|
||||
if (getTaskHandler is null && updateTaskHandler is null && cancelTaskHandler is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
getTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams));
|
||||
updateTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams));
|
||||
cancelTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams));
|
||||
|
||||
// Advertise tasks extension in server capabilities.
|
||||
ServerCapabilities.Extensions ??= new Dictionary<string, object>();
|
||||
ServerCapabilities.Extensions[McpExtensions.Tasks] = new JsonObject();
|
||||
|
||||
SetHandler(
|
||||
RequestMethods.TasksGet,
|
||||
getTaskHandler,
|
||||
McpJsonUtilities.JsonContext.Default.GetTaskRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.GetTaskResult);
|
||||
|
||||
SetHandler(
|
||||
RequestMethods.TasksUpdate,
|
||||
updateTaskHandler,
|
||||
McpJsonUtilities.JsonContext.Default.UpdateTaskRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.UpdateTaskResult);
|
||||
|
||||
SetHandler(
|
||||
RequestMethods.TasksCancel,
|
||||
cancelTaskHandler,
|
||||
McpJsonUtilities.JsonContext.Default.CancelTaskRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.CancelTaskResult);
|
||||
}
|
||||
|
||||
private void ConfigureExperimentalAndExtensions(McpServerOptions options)
|
||||
{
|
||||
ServerCapabilities.Experimental = options.Capabilities?.Experimental;
|
||||
|
|
@ -706,10 +784,11 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
{
|
||||
var listToolsHandler = options.Handlers.ListToolsHandler;
|
||||
var callToolHandler = options.Handlers.CallToolHandler;
|
||||
var callToolWithTaskHandler = options.Handlers.CallToolWithTaskHandler;
|
||||
var tools = options.ToolCollection;
|
||||
var toolsCapability = options.Capabilities?.Tools;
|
||||
|
||||
if (listToolsHandler is null && callToolHandler is null && tools is null &&
|
||||
if (listToolsHandler is null && callToolHandler is null && callToolWithTaskHandler is null && tools is null &&
|
||||
toolsCapability is null)
|
||||
{
|
||||
return;
|
||||
|
|
@ -718,10 +797,23 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
ServerCapabilities.Tools = new();
|
||||
|
||||
listToolsHandler ??= (static async (_, __) => new ListToolsResult());
|
||||
callToolHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams));
|
||||
var listChanged = toolsCapability?.ListChanged;
|
||||
|
||||
// Handle tools provided via DI by augmenting the handlers to incorporate them.
|
||||
var callToolFilters = options.Filters.Request.CallToolFilters;
|
||||
var callToolWithTaskFilters = options.Filters.Request.CallToolWithTaskFilters;
|
||||
|
||||
// Validate: cannot mix non-task filters/handler with task filters/handler.
|
||||
bool hasNonTaskPath = callToolHandler is not null || callToolFilters.Count > 0;
|
||||
bool hasTaskPath = callToolWithTaskHandler is not null || callToolWithTaskFilters.Count > 0;
|
||||
|
||||
if (hasNonTaskPath && hasTaskPath)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot mix non-task ({nameof(McpServerHandlers.CallToolHandler)}/{nameof(McpRequestFilters.CallToolFilters)}) " +
|
||||
$"with task-based ({nameof(McpServerHandlers.CallToolWithTaskHandler)}/{nameof(McpRequestFilters.CallToolWithTaskFilters)}). Use one style or the other.");
|
||||
}
|
||||
|
||||
// Handle tools provided via DI by augmenting the list handler.
|
||||
if (tools is not null)
|
||||
{
|
||||
var originalListToolsHandler = listToolsHandler;
|
||||
|
|
@ -742,103 +834,158 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
return result;
|
||||
};
|
||||
|
||||
var originalCallToolHandler = callToolHandler;
|
||||
var taskStore = options.TaskStore;
|
||||
var sendNotifications = options.SendTaskStatusNotifications;
|
||||
callToolHandler = async (request, cancellationToken) =>
|
||||
{
|
||||
if (request.MatchedPrimitive is McpServerTool tool)
|
||||
{
|
||||
var taskSupport = tool.ProtocolTool.Execution?.TaskSupport ?? ToolTaskSupport.Forbidden;
|
||||
|
||||
// Check if this is a task-augmented request
|
||||
if (request.Params?.Task is { } taskMetadata)
|
||||
{
|
||||
// Validate tool-level task support
|
||||
if (taskSupport is ToolTaskSupport.Forbidden)
|
||||
{
|
||||
throw new McpProtocolException(
|
||||
$"Tool '{tool.ProtocolTool.Name}' does not support task-augmented execution.",
|
||||
McpErrorCode.InvalidParams);
|
||||
}
|
||||
|
||||
// Task augmentation requested with immediate creation
|
||||
return await ExecuteToolAsTaskAsync(tool, request, taskMetadata, taskStore, sendNotifications, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Validate that required task support is satisfied
|
||||
if (taskSupport is ToolTaskSupport.Required)
|
||||
{
|
||||
throw new McpProtocolException(
|
||||
$"Tool '{tool.ProtocolTool.Name}' requires task-augmented execution. " +
|
||||
"Include a 'task' parameter with the request.",
|
||||
McpErrorCode.InvalidParams);
|
||||
}
|
||||
|
||||
// Normal synchronous execution
|
||||
return await tool.InvokeAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await originalCallToolHandler(request, cancellationToken).ConfigureAwait(false);
|
||||
};
|
||||
|
||||
listChanged = true;
|
||||
}
|
||||
|
||||
listToolsHandler = BuildFilterPipeline(listToolsHandler, options.Filters.Request.ListToolsFilters);
|
||||
callToolHandler = BuildFilterPipeline(callToolHandler, options.Filters.Request.CallToolFilters, handler =>
|
||||
async (request, cancellationToken) =>
|
||||
|
||||
// Build the unified task-augmented handler from one of the two paths.
|
||||
if (hasTaskPath)
|
||||
{
|
||||
// Case 2: task filter + task handler
|
||||
callToolWithTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams));
|
||||
|
||||
// Augment with DI tools.
|
||||
if (tools is not null)
|
||||
{
|
||||
// Initial handler that sets MatchedPrimitive
|
||||
if (request.Params?.Name is { } toolName && tools is not null &&
|
||||
tools.TryGetPrimitive(toolName, out var tool))
|
||||
var originalHandler = callToolWithTaskHandler;
|
||||
callToolWithTaskHandler = (request, cancellationToken) =>
|
||||
{
|
||||
request.MatchedPrimitive = tool;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await handler(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Don't log here for task-augmented calls; logging happens asynchronously
|
||||
// in ExecuteToolAsTaskAsync when the tool actually completes.
|
||||
if (result.Task is null)
|
||||
if (request.MatchedPrimitive is McpServerTool tool)
|
||||
{
|
||||
ToolCallCompleted(request.Params?.Name ?? string.Empty, result.IsError is true);
|
||||
return InvokeToolAsTask(tool, request, cancellationToken);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception e)
|
||||
return originalHandler(request, cancellationToken);
|
||||
};
|
||||
}
|
||||
|
||||
callToolWithTaskHandler = BuildFilterPipeline(callToolWithTaskHandler, callToolWithTaskFilters, BuildInitialTaskToolFilter(tools));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Case 1: non-task filter + non-task handler → apply filters, then convert to task-based
|
||||
callToolHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams));
|
||||
|
||||
// Augment with DI tools.
|
||||
if (tools is not null)
|
||||
{
|
||||
var originalHandler = callToolHandler;
|
||||
callToolHandler = (request, cancellationToken) =>
|
||||
{
|
||||
// Skip logging for OperationCanceledException when the cancellation token
|
||||
// is signaled - tool handler cancellation is an expected lifecycle event
|
||||
// (client request cancellation, session shutdown, MRTR teardown), not a
|
||||
// tool error.
|
||||
// Skip logging for InputRequiredException - it's normal MRTR control flow,
|
||||
// not an error (tools throw it to signal an InputRequiredResult).
|
||||
if (!(e is OperationCanceledException && cancellationToken.IsCancellationRequested) && e is not InputRequiredException)
|
||||
if (request.MatchedPrimitive is McpServerTool tool)
|
||||
{
|
||||
ToolCallError(request.Params?.Name ?? string.Empty, e);
|
||||
return tool.InvokeAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
if ((e is OperationCanceledException && cancellationToken.IsCancellationRequested) || e is McpProtocolException || e is InputRequiredException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
return originalHandler(request, cancellationToken);
|
||||
};
|
||||
}
|
||||
|
||||
return new()
|
||||
callToolHandler = BuildFilterPipeline(callToolHandler, callToolFilters, BuildInitialCallToolFilter(tools));
|
||||
|
||||
// Convert to task-based.
|
||||
var finalCallToolHandler = callToolHandler;
|
||||
callToolWithTaskHandler = async (request, cancellationToken) =>
|
||||
await finalCallToolHandler(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// If a task store is configured, wrap so that when the client signals task support
|
||||
// the tool execution is offloaded to the background via the store.
|
||||
if (options.TaskStore is { } taskStore)
|
||||
{
|
||||
var innerTaskHandler = callToolWithTaskHandler;
|
||||
callToolWithTaskHandler = async (request, cancellationToken) =>
|
||||
{
|
||||
if (HasTaskExtensionOptIn(request.Params?.Meta))
|
||||
{
|
||||
var taskInfo = await taskStore.CreateTaskAsync(cancellationToken).ConfigureAwait(false);
|
||||
var taskId = taskInfo.TaskId;
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
_taskCancellationSources[taskId] = cts;
|
||||
|
||||
// Capture the token synchronously before Task.Run dispatches the work.
|
||||
// The cancel handler may race with the background runner: whichever side wins
|
||||
// the TryRemove call owns disposal. If we accessed cts.Token from inside the
|
||||
// lambda after the handler had already disposed cts, we'd hit ObjectDisposedException.
|
||||
var taskCancellationToken = cts.Token;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
IsError = true,
|
||||
Content = [new TextContentBlock
|
||||
using (CreateMcpTaskScope(taskId, taskStore))
|
||||
{
|
||||
Text = e is McpException ?
|
||||
$"An error occurred invoking '{request.Params?.Name}': {e.Message}" :
|
||||
$"An error occurred invoking '{request.Params?.Name}'.",
|
||||
}],
|
||||
};
|
||||
try
|
||||
{
|
||||
var augmented = await innerTaskHandler(request, taskCancellationToken).ConfigureAwait(false);
|
||||
if (augmented.IsTask)
|
||||
{
|
||||
// The handler created its own task externally, but the client already holds
|
||||
// the store's taskId from the synchronous return below — we can't redirect.
|
||||
// Fail the store's task so the client sees a clear error instead of polling forever.
|
||||
var error = new JsonRpcErrorDetail
|
||||
{
|
||||
Code = (int)McpErrorCode.InternalError,
|
||||
Message = $"{nameof(McpServerOptions.TaskStore)} is configured and the {nameof(McpServerHandlers.CallToolWithTaskHandler)} returned IsTask = true. Use only one mechanism to create the task.",
|
||||
};
|
||||
var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.JsonContext.Default.JsonRpcErrorDetail);
|
||||
await taskStore.SetFailedAsync(taskId, errorJson).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var resultJson = JsonSerializer.SerializeToElement(augmented.Result!, McpJsonUtilities.JsonContext.Default.CallToolResult);
|
||||
await taskStore.SetCompletedAsync(taskId, resultJson).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (taskCancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await taskStore.SetCancelledAsync(taskId, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (InputRequiredException)
|
||||
{
|
||||
// MRTR (input requests) cannot be composed with the task-store wrapper for
|
||||
// [McpServerTool] methods today: the task ID was already returned synchronously,
|
||||
// so we have no way to surface InputRequiredResult to the client retroactively.
|
||||
// Fail the task with a clear, actionable error instead of leaking the raw
|
||||
// InputRequiredException through the generic catch below.
|
||||
var error = new JsonRpcErrorDetail
|
||||
{
|
||||
Code = (int)McpErrorCode.InvalidRequest,
|
||||
Message = "MRTR (input requests) and tasks cannot be composed via [McpServerTool] yet; " +
|
||||
$"use {nameof(McpServerHandlers.CallToolWithTaskHandler)} to manage the input-request loop manually within the task body.",
|
||||
};
|
||||
var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.JsonContext.Default.JsonRpcErrorDetail);
|
||||
await taskStore.SetFailedAsync(taskId, errorJson).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// SEP-2663 §186: failed.error MUST be a JSON-RPC error object {code, message, data?}.
|
||||
// McpProtocolException carries a JSON-RPC ErrorCode and is documented as safe to
|
||||
// propagate (Message + ErrorCode). For any other exception type, redact the message
|
||||
// and use InternalError (mirrors the redaction in BuildInitialCallToolFilter).
|
||||
var error = ex is McpProtocolException mcpEx
|
||||
? new JsonRpcErrorDetail { Code = (int)mcpEx.ErrorCode, Message = mcpEx.Message }
|
||||
: new JsonRpcErrorDetail { Code = (int)McpErrorCode.InternalError, Message = "An error occurred while executing the task." };
|
||||
var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.JsonContext.Default.JsonRpcErrorDetail);
|
||||
await taskStore.SetFailedAsync(taskId, errorJson).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Only the side that wins TryRemove disposes cts. This prevents a
|
||||
// double-dispose race with the default tasks/cancel handler.
|
||||
if (_taskCancellationSources.TryRemove(taskId, out var registeredCts))
|
||||
{
|
||||
registeredCts.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}, CancellationToken.None);
|
||||
|
||||
return ToCreateTaskResult(taskInfo);
|
||||
}
|
||||
});
|
||||
|
||||
return await innerTaskHandler(request, cancellationToken).ConfigureAwait(false);
|
||||
};
|
||||
}
|
||||
|
||||
ServerCapabilities.Tools.ListChanged = listChanged;
|
||||
|
||||
|
|
@ -848,145 +995,191 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
McpJsonUtilities.JsonContext.Default.ListToolsRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.ListToolsResult);
|
||||
|
||||
SetHandler(
|
||||
SetTaskAugmentedHandler(
|
||||
RequestMethods.ToolsCall,
|
||||
callToolHandler,
|
||||
callToolWithTaskHandler,
|
||||
McpJsonUtilities.JsonContext.Default.CallToolRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.CallToolResult);
|
||||
McpJsonUtilities.JsonContext.Default.CallToolResult,
|
||||
McpJsonUtilities.JsonContext.Default.CreateTaskResult);
|
||||
}
|
||||
|
||||
private void ConfigureTasks(McpServerOptions options)
|
||||
private static CreateTaskResult ToCreateTaskResult(McpTaskInfo info) => new()
|
||||
{
|
||||
var taskStore = options.TaskStore;
|
||||
TaskId = info.TaskId,
|
||||
Status = info.Status,
|
||||
CreatedAt = info.CreatedAt,
|
||||
LastUpdatedAt = info.LastUpdatedAt,
|
||||
TtlMs = info.TtlMs,
|
||||
PollIntervalMs = info.PollIntervalMs,
|
||||
StatusMessage = info.StatusMessage,
|
||||
ResultType = "task",
|
||||
};
|
||||
|
||||
// If no task store is configured, tasks are not supported
|
||||
if (taskStore is null)
|
||||
private static GetTaskResult ToGetTaskResult(McpTaskInfo info) => info.Status switch
|
||||
{
|
||||
McpTaskStatus.Working => new WorkingTaskResult
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Advertise task support in server capabilities
|
||||
ServerCapabilities.Tasks = new McpTasksCapability
|
||||
TaskId = info.TaskId,
|
||||
CreatedAt = info.CreatedAt,
|
||||
LastUpdatedAt = info.LastUpdatedAt,
|
||||
TtlMs = info.TtlMs,
|
||||
PollIntervalMs = info.PollIntervalMs,
|
||||
StatusMessage = info.StatusMessage,
|
||||
ResultType = "complete",
|
||||
},
|
||||
McpTaskStatus.Completed => new CompletedTaskResult
|
||||
{
|
||||
List = new ListMcpTasksCapability(),
|
||||
Cancel = new CancelMcpTasksCapability(),
|
||||
Requests = new RequestMcpTasksCapability
|
||||
{
|
||||
Tools = new ToolsMcpTasksCapability
|
||||
{
|
||||
Call = new CallToolMcpTasksCapability()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// tasks/get handler - Retrieve task status
|
||||
McpRequestHandler<GetTaskRequestParams, McpTask> getTaskHandler = async (request, cancellationToken) =>
|
||||
TaskId = info.TaskId,
|
||||
CreatedAt = info.CreatedAt,
|
||||
LastUpdatedAt = info.LastUpdatedAt,
|
||||
TtlMs = info.TtlMs,
|
||||
PollIntervalMs = info.PollIntervalMs,
|
||||
StatusMessage = info.StatusMessage,
|
||||
Result = info.Result ?? throw new InvalidOperationException($"Task '{info.TaskId}' is completed but has no result."),
|
||||
ResultType = "complete",
|
||||
},
|
||||
McpTaskStatus.Failed => new FailedTaskResult
|
||||
{
|
||||
if (request.Params?.TaskId is not { } taskId)
|
||||
{
|
||||
throw new McpProtocolException("Missing required parameter 'taskId'", McpErrorCode.InvalidParams);
|
||||
}
|
||||
|
||||
var task = await taskStore.GetTaskAsync(taskId, SessionId, cancellationToken).ConfigureAwait(false);
|
||||
if (task is null)
|
||||
{
|
||||
throw new McpProtocolException($"Task not found: '{taskId}'", McpErrorCode.InvalidParams);
|
||||
}
|
||||
|
||||
return task;
|
||||
};
|
||||
|
||||
// tasks/result handler - Retrieve task result (blocking until terminal status)
|
||||
McpRequestHandler<GetTaskPayloadRequestParams, JsonElement> getTaskResultHandler = (request, cancellationToken) =>
|
||||
TaskId = info.TaskId,
|
||||
CreatedAt = info.CreatedAt,
|
||||
LastUpdatedAt = info.LastUpdatedAt,
|
||||
TtlMs = info.TtlMs,
|
||||
PollIntervalMs = info.PollIntervalMs,
|
||||
StatusMessage = info.StatusMessage,
|
||||
Error = info.Error ?? throw new InvalidOperationException($"Task '{info.TaskId}' is failed but has no error."),
|
||||
ResultType = "complete",
|
||||
},
|
||||
McpTaskStatus.Cancelled => new CancelledTaskResult
|
||||
{
|
||||
return new ValueTask<JsonElement>(GetTaskResultAsync(request, cancellationToken));
|
||||
|
||||
async Task<JsonElement> GetTaskResultAsync(RequestContext<GetTaskPayloadRequestParams> request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Params?.TaskId is not { } taskId)
|
||||
{
|
||||
throw new McpProtocolException("Missing required parameter 'taskId'", McpErrorCode.InvalidParams);
|
||||
}
|
||||
|
||||
// Poll until task reaches terminal status
|
||||
while (true)
|
||||
{
|
||||
McpTask? task = await taskStore.GetTaskAsync(taskId, SessionId, cancellationToken).ConfigureAwait(false);
|
||||
if (task is null)
|
||||
{
|
||||
throw new McpProtocolException($"Task not found: '{taskId}'", McpErrorCode.InvalidParams);
|
||||
}
|
||||
|
||||
// If terminal, break and retrieve result
|
||||
if (task.Status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Poll according to task's pollInterval (default 1 second)
|
||||
var pollInterval = task.PollInterval ?? TimeSpan.FromSeconds(1);
|
||||
await Task.Delay(pollInterval, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Retrieve the stored result - already stored as JsonElement
|
||||
return await taskStore.GetTaskResultAsync(taskId, SessionId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
};
|
||||
|
||||
// tasks/list handler - List tasks with pagination
|
||||
McpRequestHandler<ListTasksRequestParams, ListTasksResult> listTasksHandler = async (request, cancellationToken) =>
|
||||
TaskId = info.TaskId,
|
||||
CreatedAt = info.CreatedAt,
|
||||
LastUpdatedAt = info.LastUpdatedAt,
|
||||
TtlMs = info.TtlMs,
|
||||
PollIntervalMs = info.PollIntervalMs,
|
||||
StatusMessage = info.StatusMessage,
|
||||
ResultType = "complete",
|
||||
},
|
||||
McpTaskStatus.InputRequired => new InputRequiredTaskResult
|
||||
{
|
||||
var cursor = request.Params?.Cursor;
|
||||
return await taskStore.ListTasksAsync(cursor, SessionId, cancellationToken).ConfigureAwait(false);
|
||||
};
|
||||
TaskId = info.TaskId,
|
||||
CreatedAt = info.CreatedAt,
|
||||
LastUpdatedAt = info.LastUpdatedAt,
|
||||
TtlMs = info.TtlMs,
|
||||
PollIntervalMs = info.PollIntervalMs,
|
||||
StatusMessage = info.StatusMessage,
|
||||
// McpTaskInfo.InputRequests is IReadOnlyDictionary (covers immutable store
|
||||
// implementations like InMemoryMcpTaskStore's ImmutableDictionary), while the wire
|
||||
// DTO uses IDictionary like every other Protocol type. Most concrete stores back
|
||||
// their dictionaries with a type that implements both interfaces (Dictionary,
|
||||
// ImmutableDictionary, ConcurrentDictionary), so the cast usually succeeds and we
|
||||
// only allocate a copy as a fallback.
|
||||
InputRequests = info.InputRequests is IDictionary<string, InputRequest> dict
|
||||
? dict
|
||||
: info.InputRequests?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)
|
||||
?? new Dictionary<string, InputRequest>(),
|
||||
ResultType = "complete",
|
||||
},
|
||||
_ => throw new InvalidOperationException($"Unknown task status: {info.Status}"),
|
||||
};
|
||||
|
||||
// tasks/cancel handler - Cancel a task
|
||||
McpRequestHandler<CancelMcpTaskRequestParams, McpTask> cancelTaskHandler = async (request, cancellationToken) =>
|
||||
{
|
||||
if (request.Params?.TaskId is not { } taskId)
|
||||
{
|
||||
throw new McpProtocolException("Missing required parameter 'taskId'", McpErrorCode.InvalidParams);
|
||||
}
|
||||
|
||||
// Signal cancellation if task is still running
|
||||
_taskCancellationTokenProvider!.Cancel(taskId);
|
||||
|
||||
// Delegate to task store - it handles idempotent cancellation
|
||||
var task = await taskStore.CancelTaskAsync(taskId, SessionId, cancellationToken).ConfigureAwait(false);
|
||||
if (task is null)
|
||||
{
|
||||
throw new McpProtocolException($"Task not found: '{taskId}'", McpErrorCode.InvalidParams);
|
||||
}
|
||||
|
||||
return task;
|
||||
};
|
||||
|
||||
// Register handlers
|
||||
SetHandler(
|
||||
RequestMethods.TasksGet,
|
||||
getTaskHandler,
|
||||
McpJsonUtilities.JsonContext.Default.GetTaskRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.McpTask);
|
||||
|
||||
SetHandler(
|
||||
RequestMethods.TasksResult,
|
||||
getTaskResultHandler,
|
||||
McpJsonUtilities.JsonContext.Default.GetTaskPayloadRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.JsonElement);
|
||||
|
||||
SetHandler(
|
||||
RequestMethods.TasksList,
|
||||
listTasksHandler,
|
||||
McpJsonUtilities.JsonContext.Default.ListTasksRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.ListTasksResult);
|
||||
|
||||
SetHandler(
|
||||
RequestMethods.TasksCancel,
|
||||
cancelTaskHandler,
|
||||
McpJsonUtilities.JsonContext.Default.CancelMcpTaskRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.McpTask);
|
||||
private static async ValueTask<ResultOrCreatedTask<CallToolResult>> InvokeToolAsTask(
|
||||
McpServerTool tool,
|
||||
RequestContext<CallToolRequestParams> request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await tool.InvokeAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private McpRequestFilter<CallToolRequestParams, CallToolResult> BuildInitialCallToolFilter(
|
||||
McpServerPrimitiveCollection<McpServerTool>? tools) => handler =>
|
||||
async (request, cancellationToken) =>
|
||||
{
|
||||
if (request.Params?.Name is { } toolName && tools is not null &&
|
||||
tools.TryGetPrimitive(toolName, out var tool))
|
||||
{
|
||||
request.MatchedPrimitive = tool;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await handler(request, cancellationToken).ConfigureAwait(false);
|
||||
ToolCallCompleted(request.Params?.Name ?? string.Empty, result.IsError is true);
|
||||
return result;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// Skip logging for InputRequiredException - it's normal MRTR control flow,
|
||||
// not an error (tools throw it to signal an InputRequiredResult).
|
||||
if (!(e is OperationCanceledException && cancellationToken.IsCancellationRequested) && e is not InputRequiredException)
|
||||
{
|
||||
ToolCallError(request.Params?.Name ?? string.Empty, e);
|
||||
}
|
||||
|
||||
if ((e is OperationCanceledException && cancellationToken.IsCancellationRequested) || e is McpProtocolException || e is InputRequiredException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
return new()
|
||||
{
|
||||
IsError = true,
|
||||
Content = [new TextContentBlock
|
||||
{
|
||||
Text = e is McpException ?
|
||||
$"An error occurred invoking '{request.Params?.Name}': {e.Message}" :
|
||||
$"An error occurred invoking '{request.Params?.Name}'.",
|
||||
}],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
private McpRequestFilter<CallToolRequestParams, ResultOrCreatedTask<CallToolResult>> BuildInitialTaskToolFilter(
|
||||
McpServerPrimitiveCollection<McpServerTool>? tools) => handler =>
|
||||
async (request, cancellationToken) =>
|
||||
{
|
||||
if (request.Params?.Name is { } toolName && tools is not null &&
|
||||
tools.TryGetPrimitive(toolName, out var tool))
|
||||
{
|
||||
request.MatchedPrimitive = tool;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await handler(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.IsTask)
|
||||
{
|
||||
ToolCallCompleted(request.Params?.Name ?? string.Empty, result.Result!.IsError is true);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// Skip logging for InputRequiredException - it's normal MRTR control flow,
|
||||
// not an error (tools throw it to signal an InputRequiredResult).
|
||||
if (!(e is OperationCanceledException && cancellationToken.IsCancellationRequested) && e is not InputRequiredException)
|
||||
{
|
||||
ToolCallError(request.Params?.Name ?? string.Empty, e);
|
||||
}
|
||||
|
||||
if ((e is OperationCanceledException && cancellationToken.IsCancellationRequested) || e is McpProtocolException || e is InputRequiredException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
return new CallToolResult
|
||||
{
|
||||
IsError = true,
|
||||
Content = [new TextContentBlock
|
||||
{
|
||||
Text = e is McpException ?
|
||||
$"An error occurred invoking '{request.Params?.Name}': {e.Message}" :
|
||||
$"An error occurred invoking '{request.Params?.Name}'.",
|
||||
}],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
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.
|
||||
|
|
@ -1088,6 +1281,20 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
requestTypeInfo, responseTypeInfo);
|
||||
}
|
||||
|
||||
private void SetTaskAugmentedHandler<TParams, TResult>(
|
||||
string method,
|
||||
McpRequestHandler<TParams, ResultOrCreatedTask<TResult>> handler,
|
||||
JsonTypeInfo<TParams> requestTypeInfo,
|
||||
JsonTypeInfo<TResult> responseTypeInfo,
|
||||
JsonTypeInfo<CreateTaskResult> taskResultTypeInfo)
|
||||
where TResult : Result
|
||||
{
|
||||
_requestHandlers.SetTaskAugmented(method,
|
||||
(request, jsonRpcRequest, cancellationToken) =>
|
||||
InvokeHandlerAsync(handler, request, jsonRpcRequest, cancellationToken),
|
||||
requestTypeInfo, responseTypeInfo, taskResultTypeInfo);
|
||||
}
|
||||
|
||||
private static McpRequestHandler<TParams, TResult> BuildFilterPipeline<TParams, TResult>(
|
||||
McpRequestHandler<TParams, TResult> baseHandler,
|
||||
IList<McpRequestFilter<TParams, TResult>> filters,
|
||||
|
|
@ -1108,6 +1315,20 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
return current;
|
||||
}
|
||||
|
||||
// Per SEP-2663 §51, the client opts in to the tasks extension on a per-request basis
|
||||
// via the SEP-2575 capabilities envelope:
|
||||
// _meta/io.modelcontextprotocol/clientCapabilities/extensions/io.modelcontextprotocol/tasks = {}
|
||||
// TODO: swap the literals for a shared NotificationMethods.ClientCapabilitiesMetaKey once
|
||||
// the SEP-2575 plumbing lands.
|
||||
private const string ClientCapabilitiesMetaKey = "io.modelcontextprotocol/clientCapabilities";
|
||||
private const string ExtensionsKey = "extensions";
|
||||
|
||||
private static bool HasTaskExtensionOptIn(JsonObject? meta) =>
|
||||
meta is not null &&
|
||||
meta[ClientCapabilitiesMetaKey] is JsonObject caps &&
|
||||
caps[ExtensionsKey] is JsonObject exts &&
|
||||
exts.ContainsKey(McpExtensions.Tasks);
|
||||
|
||||
private JsonRpcMessageFilter BuildMessageFilterPipeline(IList<McpMessageFilter> filters)
|
||||
{
|
||||
if (filters.Count == 0)
|
||||
|
|
@ -1617,160 +1838,4 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
|
||||
[LoggerMessage(Level = LogLevel.Debug, Message = "An MRTR handler threw an unhandled exception.")]
|
||||
private partial void MrtrHandlerError(Exception exception);
|
||||
|
||||
/// <summary>
|
||||
/// Executes a tool call as a task and returns a CallToolTaskResult immediately.
|
||||
/// </summary>
|
||||
private async ValueTask<CallToolResult> ExecuteToolAsTaskAsync(
|
||||
McpServerTool tool,
|
||||
RequestContext<CallToolRequestParams> request,
|
||||
McpTaskMetadata taskMetadata,
|
||||
IMcpTaskStore? taskStore,
|
||||
bool sendNotifications,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (taskStore is null)
|
||||
{
|
||||
throw new McpProtocolException(
|
||||
"Task-augmented requests are not supported. No task store configured.",
|
||||
McpErrorCode.InvalidRequest);
|
||||
}
|
||||
|
||||
// Create the task in the task store
|
||||
var mcpTask = await taskStore.CreateTaskAsync(
|
||||
taskMetadata,
|
||||
request.JsonRpcRequest.Id,
|
||||
request.JsonRpcRequest,
|
||||
SessionId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Register the task for TTL-based cancellation
|
||||
var taskCancellationToken = _taskCancellationTokenProvider!.RequestToken(mcpTask.TaskId, mcpTask.TimeToLive);
|
||||
|
||||
// Execute the tool asynchronously in the background
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
// When per-request service scoping is enabled, InvokeHandlerAsync creates a new
|
||||
// IServiceScope and disposes it once the handler returns. Since ExecuteToolAsTaskAsync
|
||||
// returns immediately (before the tool runs), the scope is disposed before the tool
|
||||
// gets a chance to resolve any DI services. Create a fresh scope here, tied to this
|
||||
// background task's lifetime, so the tool's DI resolution uses a live provider.
|
||||
var taskScope = _servicesScopePerRequest
|
||||
? Services?.GetService<IServiceScopeFactory>()?.CreateAsyncScope()
|
||||
: null;
|
||||
if (taskScope is not null)
|
||||
{
|
||||
request.Services = taskScope.Value.ServiceProvider;
|
||||
}
|
||||
|
||||
// Set up the task execution context for automatic input_required status tracking
|
||||
TaskExecutionContext.Current = new TaskExecutionContext
|
||||
{
|
||||
TaskId = mcpTask.TaskId,
|
||||
SessionId = SessionId,
|
||||
TaskStore = taskStore,
|
||||
SendNotifications = sendNotifications,
|
||||
NotifyTaskStatusFunc = NotifyTaskStatusAsync
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
// Update task status to working
|
||||
var workingTask = await taskStore.UpdateTaskStatusAsync(
|
||||
mcpTask.TaskId,
|
||||
McpTaskStatus.Working,
|
||||
null, // statusMessage
|
||||
SessionId,
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
// Send notification if enabled
|
||||
if (sendNotifications)
|
||||
{
|
||||
_ = NotifyTaskStatusAsync(workingTask, CancellationToken.None);
|
||||
}
|
||||
|
||||
// Invoke the tool with task-specific cancellation token
|
||||
var result = await tool.InvokeAsync(request, taskCancellationToken).ConfigureAwait(false);
|
||||
ToolCallCompleted(request.Params?.Name ?? string.Empty, result.IsError is true);
|
||||
|
||||
// Determine final status based on whether there was an error
|
||||
var finalStatus = result.IsError is true ? McpTaskStatus.Failed : McpTaskStatus.Completed;
|
||||
|
||||
// Store the result (serialize to JsonElement)
|
||||
var resultElement = JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.CallToolResult);
|
||||
var finalTask = await taskStore.StoreTaskResultAsync(
|
||||
mcpTask.TaskId,
|
||||
finalStatus,
|
||||
resultElement,
|
||||
SessionId,
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
// Send final notification if enabled
|
||||
if (sendNotifications)
|
||||
{
|
||||
_ = NotifyTaskStatusAsync(finalTask, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (taskCancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Task was cancelled via TTL expiration or explicit cancellation.
|
||||
// For TTL expiration, the task is deleted so no status update needed.
|
||||
// For explicit cancellation, the cancel handler already updates the status.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log the error
|
||||
ToolCallError(request.Params?.Name ?? string.Empty, ex);
|
||||
|
||||
// Store error result
|
||||
var errorResult = new CallToolResult
|
||||
{
|
||||
IsError = true,
|
||||
Content = [new TextContentBlock { Text = $"Task execution failed: {ex.Message}" }],
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var errorResultElement = JsonSerializer.SerializeToElement(errorResult, McpJsonUtilities.JsonContext.Default.CallToolResult);
|
||||
var failedTask = await taskStore.StoreTaskResultAsync(
|
||||
mcpTask.TaskId,
|
||||
McpTaskStatus.Failed,
|
||||
errorResultElement,
|
||||
SessionId,
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
// Send failure notification if enabled
|
||||
if (sendNotifications)
|
||||
{
|
||||
_ = NotifyTaskStatusAsync(failedTask, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If we can't store the error result, there's not much we can do
|
||||
// The task will remain in "working" status, which will eventually be cleaned up
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Clean up task execution context
|
||||
TaskExecutionContext.Current = null;
|
||||
|
||||
// Clean up task cancellation tracking
|
||||
_taskCancellationTokenProvider!.Complete(mcpTask.TaskId);
|
||||
|
||||
// Dispose the per-task service scope (if one was created)
|
||||
if (taskScope is not null)
|
||||
{
|
||||
await taskScope.Value.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}, CancellationToken.None);
|
||||
|
||||
// Return the task result immediately
|
||||
return new CallToolResult
|
||||
{
|
||||
Task = mcpTask
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
#pragma warning disable MCPEXP001
|
||||
|
||||
namespace ModelContextProtocol.Server;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -188,54 +190,17 @@ public sealed class McpServerOptions
|
|||
public int MaxSamplingOutputTokens { get; set; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the task store for managing asynchronous task execution.
|
||||
/// Gets or sets the task store for managing asynchronous task executions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When non-null, enables explicit task support with persistence, allowing clients to:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Execute operations asynchronously by augmenting requests with task metadata</description></item>
|
||||
/// <item><description>Poll for task status via tasks/get requests</description></item>
|
||||
/// <item><description>Retrieve task results via tasks/result requests</description></item>
|
||||
/// <item><description>List all tasks via tasks/list requests</description></item>
|
||||
/// <item><description>Cancel tasks via tasks/cancel requests</description></item>
|
||||
/// </list>
|
||||
/// When set, the server automatically enables the <c>io.modelcontextprotocol/tasks</c> extension
|
||||
/// and wires up <c>tasks/get</c>, <c>tasks/update</c>, and <c>tasks/cancel</c> handlers backed by this store.
|
||||
/// Tool executions from clients that signal task support will be wrapped in tasks via the store.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When null, implicit task support may still be available for async methods (returning <see cref="Task"/> or
|
||||
/// <see cref="ValueTask"/>), but tasks will be ephemeral and not persisted. Use <see cref="InMemoryMcpTaskStore"/>
|
||||
/// for development/testing or implement <see cref="IMcpTaskStore"/> for production scenarios.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The server will automatically advertise task capabilities based on the presence of a task store
|
||||
/// and the detection of async server primitives (tools, prompts, resources).
|
||||
/// If explicit task handlers are also set on <see cref="Handlers"/>, the explicit handlers take precedence.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
|
||||
public IMcpTaskStore? TaskStore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to send task status notifications to clients.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// <see langword="true"/> to send optional <c>notifications/tasks/status</c> notifications when task status changes;
|
||||
/// <see langword="false"/> to not send notifications. The default is <see langword="false"/>.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When enabled, the server will send <c>notifications/tasks/status</c> notifications to inform clients
|
||||
/// of task state changes. According to the MCP specification, these notifications are optional and
|
||||
/// receivers MAY send them but are not required to.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Clients must not rely on receiving these notifications and should continue polling via <c>tasks/get</c>
|
||||
/// requests to ensure they receive status updates.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Even when this is set to <see langword="true"/>, notifications are only sent when <see cref="TaskStore"/>
|
||||
/// is configured, as task-augmented requests require a task store.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
|
||||
public bool SendTaskStatusNotifications { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,7 +157,6 @@ public sealed class McpServerToolAttribute : Attribute
|
|||
internal bool? _idempotent;
|
||||
internal bool? _openWorld;
|
||||
internal bool? _readOnly;
|
||||
internal ToolTaskSupport? _taskSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="McpServerToolAttribute"/> class.
|
||||
|
|
@ -300,29 +299,4 @@ public sealed class McpServerToolAttribute : Attribute
|
|||
/// </para>
|
||||
/// </remarks>
|
||||
public string? IconSource { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the task support configuration for the tool.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// A <see cref="ToolTaskSupport"/> value indicating how the tool supports task-based invocation.
|
||||
/// The default value is <see cref="ToolTaskSupport.Forbidden"/>.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When set to <see cref="ToolTaskSupport.Forbidden"/>, clients must not attempt to invoke the tool as a task.
|
||||
/// When set to <see cref="ToolTaskSupport.Optional"/>, clients may invoke the tool as a task or as a normal request.
|
||||
/// When set to <see cref="ToolTaskSupport.Required"/>, clients must invoke the tool as a task.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If this property is not explicitly set on the attribute, the task support behavior will be determined
|
||||
/// automatically based on the tool's characteristics (e.g., async methods default to <see cref="ToolTaskSupport.Optional"/>).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
|
||||
public ToolTaskSupport TaskSupport
|
||||
{
|
||||
get => _taskSupport ?? ToolTaskSupport.Forbidden;
|
||||
set => _taskSupport = value;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -197,23 +197,6 @@ public sealed class McpServerToolCreateOptions
|
|||
/// </remarks>
|
||||
public JsonObject? Meta { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the execution hints for this tool.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Execution hints provide information about how the tool should be invoked, including
|
||||
/// task support level (<see cref="ToolTaskSupport"/>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If <see langword="null"/>, the tool's execution settings are determined automatically based on
|
||||
/// the method signature (async methods get <see cref="ToolTaskSupport.Optional"/>; sync methods
|
||||
/// get <see cref="ToolTaskSupport.Forbidden"/>).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
|
||||
public ToolExecution? Execution { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a shallow clone of the current <see cref="McpServerToolCreateOptions"/> instance.
|
||||
/// </summary>
|
||||
|
|
@ -235,6 +218,5 @@ public sealed class McpServerToolCreateOptions
|
|||
Metadata = Metadata,
|
||||
Icons = Icons,
|
||||
Meta = Meta,
|
||||
Execution = Execution,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace ModelContextProtocol.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Provides ambient context when a tool is executing as a background task.
|
||||
/// When established, calls to <see cref="McpServer.ElicitAsync(ModelContextProtocol.Protocol.ElicitRequestParams, CancellationToken)"/>,
|
||||
/// <see cref="McpServer.SampleAsync(ModelContextProtocol.Protocol.CreateMessageRequestParams, CancellationToken)"/>,
|
||||
/// and <see cref="McpServer.RequestRootsAsync(ModelContextProtocol.Protocol.ListRootsRequestParams, CancellationToken)"/>
|
||||
/// are redirected through the task store as input requests rather than sent directly to the client.
|
||||
/// </summary>
|
||||
[Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)]
|
||||
internal sealed class McpTaskExecutionContext
|
||||
{
|
||||
internal static readonly AsyncLocal<McpTaskExecutionContext?> Current = new();
|
||||
|
||||
public required string TaskId { get; init; }
|
||||
public required IMcpTaskStore Store { get; init; }
|
||||
|
||||
internal sealed class Scope(McpTaskExecutionContext? previous) : IDisposable
|
||||
{
|
||||
public void Dispose() => Current.Value = previous;
|
||||
}
|
||||
}
|
||||
28
src/ModelContextProtocol.Core/Server/McpTaskInfo.cs
Normal file
28
src/ModelContextProtocol.Core/Server/McpTaskInfo.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ModelContextProtocol.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the state of a task in an <see cref="IMcpTaskStore"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the store's representation of a task, decoupled from the MCP protocol wire types.
|
||||
/// The server infrastructure maps <see cref="McpTaskInfo"/> to the appropriate protocol response
|
||||
/// types (<see cref="CreateTaskResult"/>, <see cref="GetTaskResult"/>) when communicating with clients.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)]
|
||||
public sealed record McpTaskInfo(
|
||||
string TaskId,
|
||||
McpTaskStatus Status,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset LastUpdatedAt,
|
||||
long? TtlMs = null,
|
||||
long? PollIntervalMs = null,
|
||||
string? StatusMessage = null,
|
||||
JsonElement? Result = null,
|
||||
JsonElement? Error = null,
|
||||
IReadOnlyDictionary<string, InputRequest>? InputRequests = null);
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
namespace ModelContextProtocol.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the execution context for a task being executed by the server.
|
||||
/// This context flows with async execution and enables automatic task status updates.
|
||||
/// </summary>
|
||||
internal sealed class TaskExecutionContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the AsyncLocal instance used to track the current task execution context.
|
||||
/// </summary>
|
||||
private static readonly AsyncLocal<TaskExecutionContext?> s_current = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the current task execution context for the executing async flow.
|
||||
/// </summary>
|
||||
public static TaskExecutionContext? Current
|
||||
{
|
||||
get => s_current.Value;
|
||||
set => s_current.Value = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the task ID of the currently executing task.
|
||||
/// </summary>
|
||||
public required string TaskId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the session ID associated with the task.
|
||||
/// </summary>
|
||||
public string? SessionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the task store used to persist task state.
|
||||
/// </summary>
|
||||
public required IMcpTaskStore TaskStore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether task status notifications should be sent.
|
||||
/// </summary>
|
||||
public bool SendNotifications { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the function to call when sending a task status notification.
|
||||
/// </summary>
|
||||
public Func<Protocol.McpTask, CancellationToken, Task>? NotifyTaskStatusFunc { get; init; }
|
||||
}
|
||||
Loading…
Reference in a new issue