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

View file

@ -1,6 +1,7 @@
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Nodes;
@ -912,6 +913,325 @@ public abstract partial class McpClient : McpSession
cancellationToken: cancellationToken);
}
/// <summary>
/// Invokes a tool on the server as a task for long-running operations.
/// </summary>
/// <param name="toolName">The name of the tool to call on the server.</param>
/// <param name="arguments">An optional dictionary of arguments to pass to the tool.</param>
/// <param name="taskMetadata">Metadata for task augmentation, including optional TTL. If <see langword="null"/>, an empty metadata is used.</param>
/// <param name="progress">An optional progress reporter for server notifications.</param>
/// <param name="options">Optional request options including metadata, serialization settings, and progress tracking.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>
/// An <see cref="McpTask"/> representing the created task. Use <see cref="GetTaskAsync"/> to poll for status updates
/// and <see cref="GetTaskResultAsync"/> to retrieve the final result.
/// </returns>
/// <exception cref="ArgumentNullException"><paramref name="toolName"/> is <see langword="null"/>.</exception>
/// <exception cref="McpException">The request failed or the server returned an error response.</exception>
/// <remarks>
/// <para>
/// Task-augmented tool calls allow long-running operations to be executed asynchronously. Instead of blocking
/// until the tool completes, the server immediately returns a task identifier that can be used to poll for
/// status updates and retrieve the final result.
/// </para>
/// <para>
/// The server must advertise task support via <c>capabilities.tasks.requests.tools.call</c> and the tool
/// must have <c>execution.taskSupport</c> set to <c>"optional"</c> or <c>"required"</c>.
/// </para>
/// </remarks>
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
public ValueTask<McpTask> CallToolAsTaskAsync(
string toolName,
IReadOnlyDictionary<string, object?>? arguments = null,
McpTaskMetadata? taskMetadata = null,
IProgress<ProgressNotificationValue>? progress = null,
RequestOptions? options = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(toolName);
var serializerOptions = options?.JsonSerializerOptions ?? McpJsonUtilities.DefaultOptions;
serializerOptions.MakeReadOnly();
if (progress is null)
{
return SendTaskAugmentedCallToolRequestAsync(toolName, arguments, taskMetadata, options?.GetMetaForRequest(), serializerOptions, cancellationToken);
}
return SendTaskAugmentedCallToolRequestWithProgressAsync(toolName, arguments, taskMetadata, progress, options?.GetMetaForRequest(), serializerOptions, cancellationToken);
async ValueTask<McpTask> SendTaskAugmentedCallToolRequestAsync(
string toolName,
IReadOnlyDictionary<string, object?>? arguments,
McpTaskMetadata? taskMetadata,
JsonObject? meta,
JsonSerializerOptions serializerOptions,
CancellationToken cancellationToken)
{
var result = await SendRequestAsync(
RequestMethods.ToolsCall,
new CallToolRequestParams
{
Name = toolName,
Arguments = ToArgumentsDictionary(arguments, serializerOptions),
Meta = meta,
Task = taskMetadata ?? new McpTaskMetadata(),
},
McpJsonUtilities.JsonContext.Default.CallToolRequestParams,
McpJsonUtilities.JsonContext.Default.CreateTaskResult,
cancellationToken: cancellationToken).ConfigureAwait(false);
return result.Task;
}
async ValueTask<McpTask> SendTaskAugmentedCallToolRequestWithProgressAsync(
string toolName,
IReadOnlyDictionary<string, object?>? arguments,
McpTaskMetadata? taskMetadata,
IProgress<ProgressNotificationValue> progress,
JsonObject? meta,
JsonSerializerOptions serializerOptions,
CancellationToken cancellationToken)
{
ProgressToken progressToken = new(Guid.NewGuid().ToString("N"));
await using var _ = RegisterNotificationHandler(NotificationMethods.ProgressNotification,
(notification, cancellationToken) =>
{
if (JsonSerializer.Deserialize(notification.Params, McpJsonUtilities.JsonContext.Default.ProgressNotificationParams) is { } pn &&
pn.ProgressToken == progressToken)
{
progress.Report(pn.Progress);
}
return default;
}).ConfigureAwait(false);
JsonObject metaWithProgress = meta is not null ? new(meta) : [];
metaWithProgress["progressToken"] = progressToken.ToString();
var result = await SendRequestAsync(
RequestMethods.ToolsCall,
new CallToolRequestParams
{
Name = toolName,
Arguments = ToArgumentsDictionary(arguments, serializerOptions),
Meta = metaWithProgress,
Task = taskMetadata ?? new McpTaskMetadata(),
},
McpJsonUtilities.JsonContext.Default.CallToolRequestParams,
McpJsonUtilities.JsonContext.Default.CreateTaskResult,
cancellationToken: cancellationToken).ConfigureAwait(false);
return result.Task;
}
}
/// <summary>
/// Retrieves the current state of a specific task from the server.
/// </summary>
/// <param name="taskId">The unique identifier of the task to retrieve.</param>
/// <param name="options">Optional request options including metadata, serialization settings, and progress tracking.</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>
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
public async ValueTask<McpTask> GetTaskAsync(
string taskId,
RequestOptions? options = null,
CancellationToken cancellationToken = default)
{
Throw.IfNullOrWhiteSpace(taskId);
var result = await SendRequestAsync(
RequestMethods.TasksGet,
new GetTaskRequestParams { TaskId = taskId, Meta = options?.GetMetaForRequest() },
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, blocking until the task reaches a terminal state.
/// </summary>
/// <param name="taskId">The unique identifier of the task whose result to retrieve.</param>
/// <param name="options">Optional request options including metadata, serialization settings, and progress tracking.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The raw JSON result 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>
/// <remarks>
/// This method sends a tasks/result request to the server, which will block until the task completes if it hasn't already.
/// The server handles all polling logic internally.
/// </remarks>
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
public ValueTask<JsonElement> GetTaskResultAsync(
string taskId,
RequestOptions? options = null,
CancellationToken cancellationToken = default)
{
Throw.IfNullOrWhiteSpace(taskId);
return SendRequestAsync(
RequestMethods.TasksResult,
new GetTaskPayloadRequestParams { TaskId = taskId, Meta = options?.GetMetaForRequest() },
McpJsonUtilities.JsonContext.Default.GetTaskPayloadRequestParams,
McpJsonUtilities.JsonContext.Default.JsonElement,
cancellationToken: cancellationToken);
}
/// <summary>
/// Retrieves a list of all tasks from the server.
/// </summary>
/// <param name="options">Optional request options including metadata, serialization settings, and progress tracking.</param>
/// <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>
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
public async ValueTask<IList<McpTask>> ListTasksAsync(
RequestOptions? options = null,
CancellationToken cancellationToken = default)
{
ListTasksRequestParams requestParams = new() { Meta = options?.GetMetaForRequest() };
List<McpTask> tasks = new();
do
{
var taskResults = await ListTasksAsync(requestParams, cancellationToken).ConfigureAwait(false);
tasks.AddRange(taskResults.Tasks);
requestParams.Cursor = taskResults.NextCursor;
}
while (requestParams.Cursor is not null);
return tasks;
}
/// <summary>
/// Retrieves a list of tasks from the server.
/// </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 server.</returns>
/// <exception cref="ArgumentNullException"><paramref name="requestParams"/> is <see langword="null"/>.</exception>
/// <remarks>
/// The <see cref="ListTasksAsync(RequestOptions?, 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 server.
/// 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);
return SendRequestAsync(
RequestMethods.TasksList,
requestParams,
McpJsonUtilities.JsonContext.Default.ListTasksRequestParams,
McpJsonUtilities.JsonContext.Default.ListTasksResult,
cancellationToken: cancellationToken);
}
/// <summary>
/// Cancels a running task on the server.
/// </summary>
/// <param name="taskId">The unique identifier of the task to cancel.</param>
/// <param name="options">Optional request options including metadata, serialization settings, and progress tracking.</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>
/// <remarks>
/// Cancelling a task requests that the server stop execution. The server 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,
RequestOptions? options = null,
CancellationToken cancellationToken = default)
{
Throw.IfNullOrWhiteSpace(taskId);
var result = await SendRequestAsync(
RequestMethods.TasksCancel,
new CancelMcpTaskRequestParams { TaskId = taskId, Meta = options?.GetMetaForRequest() },
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 until it reaches a terminal status (completed, failed, or cancelled).
/// </summary>
/// <param name="taskId">The unique identifier of the task to poll.</param>
/// <param name="options">Optional request options including metadata, serialization settings, and progress tracking.</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>
/// <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 server to determine how long
/// to wait between polling attempts.
/// </para>
/// <para>
/// For retrieving the actual result of a completed task, use <see cref="GetTaskResultAsync"/>.
/// </para>
/// </remarks>
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
public async ValueTask<McpTask> PollTaskUntilCompleteAsync(
string taskId,
RequestOptions? options = null,
CancellationToken cancellationToken = default)
{
Throw.IfNullOrWhiteSpace(taskId);
McpTask task;
do
{
task = await GetTaskAsync(taskId, options, 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>
/// Sets the logging level for the server to control which log messages are sent to the client.
/// </summary>