Implement MCP task support (SEP-1686) (#1170)

Co-authored-by: Stephen Halter <halter73@gmail.com>
This commit is contained in:
Eirik Tsarpalis 2026-01-28 05:21:02 +02:00 • committed by GitHub
commit 10e4f5b20f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 11580 additions and 46 deletions

View file

@ -148,6 +148,23 @@ 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 ?? []);
@ -188,6 +205,12 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
}
newOptions.UseStructuredContent = toolAttr.UseStructuredContent;
if (toolAttr._taskSupport is { } taskSupport)
{
newOptions.Execution ??= new ToolExecution();
newOptions.Execution.TaskSupport ??= taskSupport;
}
}
if (method.GetCustomAttribute<DescriptionAttribute>() is { } descAttr)
@ -315,27 +338,27 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
// Case the name based on the provided naming policy.
return (policy ?? JsonNamingPolicy.SnakeCaseLower).ConvertName(name) ?? name;
}
static bool IsAsyncMethod(MethodInfo method)
private static bool IsAsyncMethod(MethodInfo method)
{
Type t = method.ReturnType;
if (t == typeof(Task) || t == typeof(ValueTask))
{
Type t = method.ReturnType;
return true;
}
if (t == typeof(Task) || t == typeof(ValueTask))
if (t.IsGenericType)
{
t = t.GetGenericTypeDefinition();
if (t == typeof(Task<>) || t == typeof(ValueTask<>) || t == typeof(IAsyncEnumerable<>))
{
return true;
}
if (t.IsGenericType)
{
t = t.GetGenericTypeDefinition();
if (t == typeof(Task<>) || t == typeof(ValueTask<>) || t == typeof(IAsyncEnumerable<>))
{
return true;
}
}
return false;
}
return false;
}
/// <summary>Creates metadata from attributes on the specified method and its declaring class, with the MethodInfo as the first item.</summary>

View file

@ -0,0 +1,168 @@
using ModelContextProtocol.Protocol;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
namespace ModelContextProtocol;
/// <summary>
/// Provides an interface for pluggable task storage implementations in MCP servers.
/// </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.
/// </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.
/// </para>
/// </remarks>
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
public interface IMcpTaskStore
{
/// <summary>
/// Creates a new task for tracking an asynchronous operation.
/// </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).
/// </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.
/// </remarks>
Task<McpTask> CreateTaskAsync(
McpTaskMetadata taskParams,
RequestId requestId,
JsonRpcRequest request,
string? sessionId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves a task by its unique identifier.
/// </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"/>.
/// </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);
/// <summary>
/// Stores the final result of a task that has reached a terminal status.
/// </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="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A task representing the asynchronous operation.</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>
/// <returns>The updated <see cref="McpTask"/> with the new status and result stored.</returns>
Task<McpTask> StoreTaskResultAsync(
string taskId,
McpTaskStatus status,
JsonElement result,
string? sessionId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves the stored result of a completed or failed task.
/// </summary>
/// <param name="taskId">The unique identifier of the task.</param>
/// <param name="sessionId">Optional session identifier for access control.</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);
/// <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>A task representing the asynchronous operation.</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>
/// <returns>The updated <see cref="McpTask"/> with the new status applied.</returns>
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.
/// </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.
/// </returns>
/// <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.
/// </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.
/// </para>
/// </remarks>
Task<McpTask> CancelTaskAsync(string taskId, string? sessionId = null, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,504 @@
using ModelContextProtocol.Protocol;
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
namespace ModelContextProtocol;
/// <summary>
/// Provides an in-memory implementation of <see cref="IMcpTaskStore"/> for development and testing.
/// </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.
/// </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>
/// </para>
/// </remarks>
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
public sealed class InMemoryMcpTaskStore : IMcpTaskStore, IDisposable
{
private readonly ConcurrentDictionary<string, TaskEntry> _tasks = new();
private readonly TimeSpan? _defaultTtl;
private readonly TimeSpan? _maxTtl;
private readonly TimeSpan _pollInterval;
private readonly Timer? _cleanupTimer;
private readonly int _pageSize;
private readonly int? _maxTasks;
private readonly int? _maxTasksPerSession;
/// <summary>
/// Initializes a new instance of the <see cref="InMemoryMcpTaskStore"/> class.
/// </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));
}
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;
cleanupInterval ??= TimeSpan.FromMinutes(1);
if (cleanupInterval.Value != Timeout.InfiniteTimeSpan)
{
_cleanupTimer = new Timer(CleanupExpiredTasks, null, cleanupInterval.Value, cleanupInterval.Value);
}
}
/// <inheritdoc/>
public Task<McpTask> CreateTaskAsync(
McpTaskMetadata taskParams,
RequestId requestId,
JsonRpcRequest request,
string? sessionId = null,
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.");
}
// 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 taskId = GenerateTaskId();
var now = DateTimeOffset.UtcNow;
// 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());
}
/// <inheritdoc/>
public Task<McpTask?> GetTaskAsync(string taskId, string? sessionId = null, 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());
}
/// <inheritdoc/>
public Task<McpTask> StoreTaskResultAsync(
string taskId,
McpTaskStatus status,
JsonElement result,
string? sessionId = null,
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))
{
throw new InvalidOperationException($"Task not found: {taskId}");
}
// 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 = DateTimeOffset.UtcNow,
StoredResult = result
};
if (_tasks.TryUpdate(taskId, updatedEntry, entry))
{
return Task.FromResult(updatedEntry.ToMcpTask());
}
// Entry was modified by another thread, retry
}
}
/// <inheritdoc/>
public Task<JsonElement> GetTaskResultAsync(string taskId, string? sessionId = null, CancellationToken cancellationToken = default)
{
if (!_tasks.TryGetValue(taskId, out var entry))
{
throw new InvalidOperationException($"Task not found: {taskId}");
}
// 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);
}
/// <inheritdoc/>
public Task<McpTask> UpdateTaskStatusAsync(
string taskId,
McpTaskStatus status,
string? statusMessage,
string? sessionId = null,
CancellationToken cancellationToken = default)
{
// Retry loop for optimistic concurrency
while (true)
{
if (!_tasks.TryGetValue(taskId, out var entry))
{
throw new InvalidOperationException($"Task not found: {taskId}");
}
// 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 = DateTimeOffset.UtcNow,
};
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)
{
// Parse cursor: format is "CreatedAt|TaskId" for keyset pagination
(DateTimeOffset, string)? parsedCursor = null;
if (cursor != null)
{
var parts = cursor.Split('|');
if (parts.Length == 2 &&
DateTimeOffset.TryParse(parts[0], out var parsedDate))
{
parsedCursor = (parsedDate, parts[1]);
}
}
// 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: (CreatedAt, TaskId) > cursor
if (parsedCursor is { } parsedCursorValue)
{
query = query.Where(e => (e.CreatedAt, e.TaskId).CompareTo(parsedCursorValue) > 0);
}
// Order by (CreatedAt, TaskId) for stable, deterministic pagination
var page = query
.OrderBy(e => (e.CreatedAt, e.TaskId))
.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.CreatedAt:O}|{lastItemInPage.TaskId}";
page.RemoveAt(_pageSize); // Remove the extra item
}
else
{
nextCursor = null;
}
return Task.FromResult(new ListTasksResult
{
Tasks = page.ToArray(),
NextCursor = nextCursor
});
}
/// <inheritdoc/>
public Task<McpTask> CancelTaskAsync(string taskId, string? sessionId = null, CancellationToken cancellationToken = default)
{
// Retry loop for optimistic concurrency
while (true)
{
if (!_tasks.TryGetValue(taskId, out var entry))
{
throw new InvalidOperationException($"Task not found: {taskId}");
}
// Enforce session isolation
if (sessionId != null && entry.SessionId != sessionId)
{
throw new InvalidOperationException($"Task not found: {taskId}");
}
// If already in terminal state, return unchanged
if (IsTerminalStatus(entry.Status))
{
return Task.FromResult(entry.ToMcpTask());
}
var updatedEntry = new TaskEntry(entry)
{
Status = McpTaskStatus.Cancelled,
LastUpdatedAt = DateTimeOffset.UtcNow,
};
if (_tasks.TryUpdate(taskId, updatedEntry, entry))
{
return Task.FromResult(updatedEntry.ToMcpTask());
}
// Entry was modified by another thread, retry
}
}
/// <summary>
/// Disposes the task store and stops background cleanup.
/// </summary>
public void Dispose()
{
_cleanupTimer?.Dispose();
}
private static string GenerateTaskId() => Guid.NewGuid().ToString("N");
private static bool IsTerminalStatus(McpTaskStatus status) =>
status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled;
private static bool IsExpired(TaskEntry entry)
{
if (entry.TimeToLive == null)
{
return false; // Unlimited lifetime
}
var expirationTime = entry.CreatedAt + entry.TimeToLive.Value;
return DateTimeOffset.UtcNow >= 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
};
}
}

View file

@ -1,4 +1,4 @@
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;
using System.Collections.Concurrent;
@ -53,19 +53,63 @@ public abstract partial class McpServer : McpSession
/// <exception cref="ArgumentNullException"><paramref name="requestParams"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">The client does not support sampling.</exception>
/// <exception cref="McpException">The request failed or the client returned an error response.</exception>
public ValueTask<CreateMessageResult> SampleAsync(
/// <remarks>
/// When called during task-augmented tool execution, this method automatically updates the task
/// status to <see cref="McpTaskStatus.InputRequired"/> while waiting for the client response,
/// then returns to <see cref="McpTaskStatus.Working"/> when the response is received.
/// </remarks>
public async ValueTask<CreateMessageResult> SampleAsync(
CreateMessageRequestParams requestParams,
CancellationToken cancellationToken = default)
{
Throw.IfNull(requestParams);
ThrowIfSamplingUnsupported();
return SendRequestAsync(
return await SendRequestWithTaskStatusTrackingAsync(
RequestMethods.SamplingCreateMessage,
requestParams,
McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams,
McpJsonUtilities.JsonContext.Default.CreateMessageResult,
cancellationToken: cancellationToken);
"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;
}
/// <summary>
@ -253,21 +297,360 @@ public abstract partial class McpServer : McpSession
/// <exception cref="ArgumentNullException"><paramref name="requestParams"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">The client does not support elicitation.</exception>
/// <exception cref="McpException">The request failed or the client returned an error response.</exception>
public ValueTask<ElicitResult> ElicitAsync(
/// <remarks>
/// When called during task-augmented tool execution, this method automatically updates the task
/// status to <see cref="McpTaskStatus.InputRequired"/> while waiting for user input,
/// then returns to <see cref="McpTaskStatus.Working"/> when the response is received.
/// </remarks>
public async ValueTask<ElicitResult> ElicitAsync(
ElicitRequestParams requestParams,
CancellationToken cancellationToken = default)
{
Throw.IfNull(requestParams);
ThrowIfElicitationUnsupported(requestParams);
return SendRequestAsync(
return await SendRequestWithTaskStatusTrackingAsync(
RequestMethods.ElicitationCreate,
requestParams,
McpJsonUtilities.JsonContext.Default.ElicitRequestParams,
McpJsonUtilities.JsonContext.Default.ElicitResult,
"Waiting for user input",
cancellationToken).ConfigureAwait(false);
}
/// <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.Length);
}
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"/>.
@ -523,6 +906,120 @@ 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
{
@ -560,6 +1057,49 @@ 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>
/// <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.

View file

@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol.Protocol;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
namespace ModelContextProtocol.Server;
@ -24,6 +25,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 ClientCapabilities? _clientCapabilities;
private Implementation? _clientInfo;
@ -66,6 +68,12 @@ 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;
UpdateEndpointNameWithClientInfo();
@ -78,6 +86,7 @@ internal sealed partial class McpServerImpl : McpServer
ConfigureTools(options);
ConfigurePrompts(options);
ConfigureResources(options);
ConfigureTasks(options);
ConfigureLogging(options);
ConfigureCompletion(options);
ConfigureExperimental(options);
@ -179,6 +188,7 @@ internal sealed partial class McpServerImpl : McpServer
_disposed = true;
_taskCancellationTokenProvider?.Dispose();
_disposables.ForEach(d => d());
await _sessionHandler.DisposeAsync().ConfigureAwait(false);
}
@ -535,14 +545,43 @@ internal sealed partial class McpServerImpl : McpServer
};
var originalCallToolHandler = callToolHandler;
callToolHandler = (request, cancellationToken) =>
var taskStore = options.TaskStore;
var sendNotifications = options.SendTaskStatusNotifications;
callToolHandler = async (request, cancellationToken) =>
{
if (request.MatchedPrimitive is McpServerTool tool)
{
return tool.InvokeAsync(request, cancellationToken);
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.MethodNotFound);
}
// Task augmentation requested - return CreateTaskResult
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.MethodNotFound);
}
// Normal synchronous execution
return await tool.InvokeAsync(request, cancellationToken).ConfigureAwait(false);
}
return originalCallToolHandler(request, cancellationToken);
return await originalCallToolHandler(request, cancellationToken).ConfigureAwait(false);
};
listChanged = true;
@ -594,6 +633,138 @@ internal sealed partial class McpServerImpl : McpServer
McpJsonUtilities.JsonContext.Default.CallToolResult);
}
private void ConfigureTasks(McpServerOptions options)
{
var taskStore = options.TaskStore;
// If no task store is configured, tasks are not supported
if (taskStore is null)
{
return;
}
// Advertise task support in server capabilities
ServerCapabilities.Tasks = new McpTasksCapability
{
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) =>
{
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) =>
{
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) =>
{
var cursor = request.Params?.Cursor;
return await taskStore.ListTasksAsync(cursor, SessionId, cancellationToken).ConfigureAwait(false);
};
// 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 void ConfigureLogging(McpServerOptions options)
{
// We don't require that the handler be provided, as we always store the provided log level to the server.
@ -729,4 +900,140 @@ internal sealed partial class McpServerImpl : McpServer
[LoggerMessage(Level = LogLevel.Error, Message = "\"{ToolName}\" threw an unhandled exception.")]
private partial void ToolCallError(string toolName, 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 () =>
{
// 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);
// 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);
}
}, CancellationToken.None);
// Return the task result immediately
return new CallToolResult
{
Task = mcpTask
};
}
}

View file

@ -1,4 +1,5 @@
using ModelContextProtocol.Protocol;
using System.Diagnostics.CodeAnalysis;
namespace ModelContextProtocol.Server;
@ -164,4 +165,56 @@ public sealed class McpServerOptions
/// when <see cref="Microsoft.Extensions.AI.ChatOptions.MaxOutputTokens"/> is not set in the request options.
/// </remarks>
public int MaxSamplingOutputTokens { get; set; } = 1000;
/// <summary>
/// Gets or sets the task store for managing asynchronous task execution.
/// </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>
/// </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).
/// </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; }
}

View file

@ -1,6 +1,7 @@
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Protocol;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
namespace ModelContextProtocol.Server;
@ -136,6 +137,7 @@ 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.
@ -257,4 +259,29 @@ 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;
}
}

View file

@ -1,6 +1,7 @@
using Microsoft.Extensions.AI;
using ModelContextProtocol.Protocol;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Nodes;
@ -176,6 +177,23 @@ 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>
@ -196,5 +214,6 @@ public sealed class McpServerToolCreateOptions
Metadata = Metadata,
Icons = Icons,
Meta = Meta,
Execution = Execution,
};
}

View file

@ -0,0 +1,47 @@
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; }
}