Extract Tasks into the ModelContextProtocol.Extensions.Tasks extension package (#1693)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Tarek Mahmoud Sayed <tarekms@ntdev.microsoft.com>
This commit is contained in:
parent
ffc557f920
commit
2ca96f548b
20 changed files with 2559 additions and 1895 deletions
|
|
@ -0,0 +1,370 @@
|
|||
using ModelContextProtocol;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ModelContextProtocol.Extensions.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for task-aware client operations.
|
||||
/// </summary>
|
||||
public static class McpTasksClientExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Calls a tool and returns either an immediate result or a created task.
|
||||
/// </summary>
|
||||
public static async ValueTask<ResultOrCreatedTask<CallToolResult>> CallToolAsTaskAsync(
|
||||
this McpClient client,
|
||||
CallToolRequestParams requestParams,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
#if NET
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
ArgumentNullException.ThrowIfNull(requestParams);
|
||||
#else
|
||||
if (client is null) throw new ArgumentNullException(nameof(client));
|
||||
if (requestParams is null) throw new ArgumentNullException(nameof(requestParams));
|
||||
#endif
|
||||
|
||||
var paramsWithMeta = new CallToolRequestParams
|
||||
{
|
||||
Name = requestParams.Name,
|
||||
Arguments = requestParams.Arguments,
|
||||
Meta = IsJuly2026OrLaterProtocol(client) ? GetMetaWithTaskCapability(requestParams.Meta) : requestParams.Meta,
|
||||
};
|
||||
|
||||
JsonRpcRequest jsonRpcRequest = new()
|
||||
{
|
||||
Method = RequestMethods.ToolsCall,
|
||||
Params = JsonSerializer.SerializeToNode(paramsWithMeta, McpJsonUtilities.DefaultOptions.GetTypeInfo<CallToolRequestParams>()),
|
||||
};
|
||||
|
||||
JsonRpcResponse response = await client.SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.Result is JsonObject resultObj &&
|
||||
resultObj.TryGetPropertyValue("resultType", out var resultTypeNode) &&
|
||||
string.Equals(resultTypeNode?.GetValue<string>(), "task", StringComparison.Ordinal))
|
||||
{
|
||||
var taskCreated = resultObj.Deserialize(McpTasksJsonContext.Default.CreateTaskResult)
|
||||
?? throw new JsonException("Failed to deserialize CreateTaskResult from response.");
|
||||
return new ResultOrCreatedTask<CallToolResult>(taskCreated);
|
||||
}
|
||||
|
||||
var callToolResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions.GetTypeInfo<CallToolResult>())
|
||||
?? throw new JsonException("Failed to deserialize CallToolResult from response.");
|
||||
return new ResultOrCreatedTask<CallToolResult>(callToolResult);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls a tool and, if needed, polls the created task to completion.
|
||||
/// </summary>
|
||||
public static async ValueTask<CallToolResult> CallToolWithPollingAsync(
|
||||
this McpClient client,
|
||||
CallToolRequestParams requestParams,
|
||||
int maxConsecutiveStuckPolls = 60,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
#if NET
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
ArgumentNullException.ThrowIfNull(requestParams);
|
||||
#else
|
||||
if (client is null) throw new ArgumentNullException(nameof(client));
|
||||
if (requestParams is null) throw new ArgumentNullException(nameof(requestParams));
|
||||
#endif
|
||||
|
||||
var augmented = await client.CallToolAsTaskAsync(requestParams, cancellationToken).ConfigureAwait(false);
|
||||
if (!augmented.IsTask)
|
||||
{
|
||||
return augmented.Result!;
|
||||
}
|
||||
|
||||
return await PollTaskToCompletionAsync(client, augmented.TaskCreated!, maxConsecutiveStuckPolls, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a task by ID.
|
||||
/// </summary>
|
||||
public static ValueTask<GetTaskResult> GetTaskAsync(
|
||||
this McpClient client,
|
||||
string taskId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
#if NET
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
ArgumentNullException.ThrowIfNull(taskId);
|
||||
#else
|
||||
if (client is null) throw new ArgumentNullException(nameof(client));
|
||||
if (taskId is null) throw new ArgumentNullException(nameof(taskId));
|
||||
#endif
|
||||
|
||||
return client.GetTaskAsync(new GetTaskRequestParams { TaskId = taskId }, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a task using explicit request parameters.
|
||||
/// </summary>
|
||||
public static ValueTask<GetTaskResult> GetTaskAsync(
|
||||
this McpClient client,
|
||||
GetTaskRequestParams requestParams,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
#if NET
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
ArgumentNullException.ThrowIfNull(requestParams);
|
||||
#else
|
||||
if (client is null) throw new ArgumentNullException(nameof(client));
|
||||
if (requestParams is null) throw new ArgumentNullException(nameof(requestParams));
|
||||
#endif
|
||||
|
||||
ThrowIfTasksNotSupported(client, nameof(GetTaskAsync));
|
||||
return client.SendRequestAsync<GetTaskRequestParams, GetTaskResult>(
|
||||
TasksProtocol.MethodTasksGet,
|
||||
requestParams,
|
||||
McpTasksJsonContext.Default.Options,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a task with input responses.
|
||||
/// </summary>
|
||||
public static async ValueTask<UpdateTaskResult> UpdateTaskAsync(
|
||||
this McpClient client,
|
||||
UpdateTaskRequestParams requestParams,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
#if NET
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
ArgumentNullException.ThrowIfNull(requestParams);
|
||||
#else
|
||||
if (client is null) throw new ArgumentNullException(nameof(client));
|
||||
if (requestParams is null) throw new ArgumentNullException(nameof(requestParams));
|
||||
#endif
|
||||
|
||||
ThrowIfTasksNotSupported(client, nameof(UpdateTaskAsync));
|
||||
|
||||
// Manually construct the JSON params because InputResponses is backed by an internal
|
||||
// property in Core that the extension's source-gen context cannot access for serialization.
|
||||
JsonObject paramsObj = new()
|
||||
{
|
||||
["taskId"] = requestParams.TaskId,
|
||||
};
|
||||
|
||||
if (requestParams.InputResponses is { Count: > 0 } inputResponses)
|
||||
{
|
||||
paramsObj["inputResponses"] = JsonSerializer.SerializeToNode(
|
||||
inputResponses,
|
||||
McpJsonUtilities.DefaultOptions.GetTypeInfo<IDictionary<string, InputResponse>>());
|
||||
}
|
||||
|
||||
JsonRpcRequest jsonRpcRequest = new()
|
||||
{
|
||||
Method = TasksProtocol.MethodTasksUpdate,
|
||||
Params = paramsObj,
|
||||
};
|
||||
|
||||
JsonRpcResponse response = await client.SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false);
|
||||
return response.Result?.Deserialize(McpTasksJsonContext.Default.UpdateTaskResult)
|
||||
?? new UpdateTaskResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requests task cancellation by ID.
|
||||
/// </summary>
|
||||
public static ValueTask<CancelTaskResult> CancelTaskAsync(
|
||||
this McpClient client,
|
||||
string taskId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
#if NET
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
ArgumentNullException.ThrowIfNull(taskId);
|
||||
#else
|
||||
if (client is null) throw new ArgumentNullException(nameof(client));
|
||||
if (taskId is null) throw new ArgumentNullException(nameof(taskId));
|
||||
#endif
|
||||
|
||||
return client.CancelTaskAsync(new CancelTaskRequestParams { TaskId = taskId }, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requests task cancellation using explicit request parameters.
|
||||
/// </summary>
|
||||
public static ValueTask<CancelTaskResult> CancelTaskAsync(
|
||||
this McpClient client,
|
||||
CancelTaskRequestParams requestParams,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
#if NET
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
ArgumentNullException.ThrowIfNull(requestParams);
|
||||
#else
|
||||
if (client is null) throw new ArgumentNullException(nameof(client));
|
||||
if (requestParams is null) throw new ArgumentNullException(nameof(requestParams));
|
||||
#endif
|
||||
|
||||
ThrowIfTasksNotSupported(client, nameof(CancelTaskAsync));
|
||||
return client.SendRequestAsync<CancelTaskRequestParams, CancelTaskResult>(
|
||||
TasksProtocol.MethodTasksCancel,
|
||||
requestParams,
|
||||
McpTasksJsonContext.Default.Options,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private static async ValueTask<CallToolResult> PollTaskToCompletionAsync(
|
||||
McpClient client,
|
||||
CreateTaskResult taskCreated,
|
||||
int maxConsecutiveStuckPolls,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string taskId = taskCreated.TaskId;
|
||||
long pollIntervalMs = taskCreated.PollIntervalMs ?? 1000;
|
||||
HashSet<string>? resolvedRequestKeys = null;
|
||||
bool isFirstPoll = true;
|
||||
int consecutiveStuckPolls = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (!isFirstPoll)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(pollIntervalMs), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
isFirstPoll = false;
|
||||
|
||||
var taskResult = await GetTaskAsync(client, taskId, cancellationToken).ConfigureAwait(false);
|
||||
if (taskResult.PollIntervalMs is { } newInterval)
|
||||
{
|
||||
pollIntervalMs = newInterval;
|
||||
}
|
||||
|
||||
switch (taskResult)
|
||||
{
|
||||
case CompletedTaskResult completed:
|
||||
return JsonSerializer.Deserialize(completed.Result, McpJsonUtilities.DefaultOptions.GetTypeInfo<CallToolResult>())
|
||||
?? throw new JsonException("Failed to deserialize CallToolResult from completed task.");
|
||||
|
||||
case FailedTaskResult failed:
|
||||
throw new McpException($"Task '{taskId}' failed: {failed.Error}");
|
||||
|
||||
case CancelledTaskResult:
|
||||
throw new OperationCanceledException($"Task '{taskId}' was cancelled by the server.");
|
||||
|
||||
case InputRequiredTaskResult inputRequired:
|
||||
var newRequests = new Dictionary<string, InputRequest>();
|
||||
if (inputRequired.InputRequests is { } incomingRequests)
|
||||
{
|
||||
foreach (var kvp in incomingRequests)
|
||||
{
|
||||
if (resolvedRequestKeys is null || !resolvedRequestKeys.Contains(kvp.Key))
|
||||
{
|
||||
newRequests[kvp.Key] = kvp.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newRequests.Count > 0)
|
||||
{
|
||||
consecutiveStuckPolls = 0;
|
||||
|
||||
IDictionary<string, InputResponse> inputResponses;
|
||||
try
|
||||
{
|
||||
inputResponses = await client.ResolveInputRequestsAsync(newRequests, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
try
|
||||
{
|
||||
await CancelTaskAsync(client, taskId, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
await UpdateTaskAsync(client, new UpdateTaskRequestParams
|
||||
{
|
||||
TaskId = taskId,
|
||||
InputResponses = inputResponses,
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
resolvedRequestKeys ??= new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var key in inputResponses.Keys)
|
||||
{
|
||||
resolvedRequestKeys.Add(key);
|
||||
}
|
||||
}
|
||||
else if (++consecutiveStuckPolls >= maxConsecutiveStuckPolls)
|
||||
{
|
||||
try
|
||||
{
|
||||
await CancelTaskAsync(client, taskId, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
throw new McpException(
|
||||
$"Task '{taskId}' has remained in '{McpTaskStatus.InputRequired}' for {maxConsecutiveStuckPolls} consecutive polls " +
|
||||
"without publishing new input requests after all previously requested inputs were resolved.");
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case WorkingTaskResult:
|
||||
consecutiveStuckPolls = 0;
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new McpException($"Unexpected task result type '{taskResult.GetType().Name}' for task '{taskId}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonObject GetMetaWithTaskCapability(JsonObject? existingMeta)
|
||||
{
|
||||
JsonObject meta = existingMeta is not null
|
||||
? (JsonObject)existingMeta.DeepClone()
|
||||
: [];
|
||||
|
||||
if (meta[MetaKeys.ClientCapabilities] is not JsonObject capsRoot)
|
||||
{
|
||||
capsRoot = [];
|
||||
meta[MetaKeys.ClientCapabilities] = capsRoot;
|
||||
}
|
||||
|
||||
if (capsRoot["extensions"] is not JsonObject extensionsRoot)
|
||||
{
|
||||
extensionsRoot = [];
|
||||
capsRoot["extensions"] = extensionsRoot;
|
||||
}
|
||||
|
||||
if (!extensionsRoot.ContainsKey(TasksProtocol.ExtensionId))
|
||||
{
|
||||
extensionsRoot[TasksProtocol.ExtensionId] = new JsonObject();
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
private static bool IsJuly2026OrLaterProtocol(McpClient client) =>
|
||||
McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(client.NegotiatedProtocolVersion);
|
||||
|
||||
private static void ThrowIfTasksNotSupported(McpClient client, string operationName)
|
||||
{
|
||||
if (!IsJuly2026OrLaterProtocol(client))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"'{operationName}' requires a newer protocol revision that supports tasks " +
|
||||
$"(the '{McpProtocolVersions.July2026ProtocolVersion}' revision or later). " +
|
||||
$"The negotiated protocol version is '{client.NegotiatedProtocolVersion ?? "(none)"}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
using System.Text.Json.Serialization;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace ModelContextProtocol.Extensions.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Provides source-generated JSON serialization metadata for MCP Tasks extension types.
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||
[JsonSerializable(typeof(CreateTaskResult))]
|
||||
[JsonSerializable(typeof(GetTaskRequestParams))]
|
||||
[JsonSerializable(typeof(GetTaskResult))]
|
||||
[JsonSerializable(typeof(WorkingTaskResult))]
|
||||
[JsonSerializable(typeof(CompletedTaskResult))]
|
||||
[JsonSerializable(typeof(FailedTaskResult))]
|
||||
[JsonSerializable(typeof(CancelledTaskResult))]
|
||||
[JsonSerializable(typeof(InputRequiredTaskResult))]
|
||||
[JsonSerializable(typeof(UpdateTaskRequestParams))]
|
||||
[JsonSerializable(typeof(UpdateTaskResult))]
|
||||
[JsonSerializable(typeof(CancelTaskRequestParams))]
|
||||
[JsonSerializable(typeof(CancelTaskResult))]
|
||||
[JsonSerializable(typeof(TaskStatusNotificationParams))]
|
||||
[JsonSerializable(typeof(WorkingTaskNotificationParams))]
|
||||
[JsonSerializable(typeof(CompletedTaskNotificationParams))]
|
||||
[JsonSerializable(typeof(FailedTaskNotificationParams))]
|
||||
[JsonSerializable(typeof(CancelledTaskNotificationParams))]
|
||||
[JsonSerializable(typeof(InputRequiredTaskNotificationParams))]
|
||||
public sealed partial class McpTasksJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0;net9.0;net8.0;netstandard2.0</TargetFrameworks>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>ModelContextProtocol.Extensions.Tasks</PackageId>
|
||||
<Description>MCP Tasks extension for the .NET Model Context Protocol (MCP) SDK</Description>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<!-- Suppress the experimental MCP warnings for internal usage -->
|
||||
<NoWarn>$(NoWarn);MCPEXP001;MCPEXP002</NoWarn>
|
||||
<!-- New package with no published baseline yet -->
|
||||
<EnablePackageValidation>false</EnablePackageValidation>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(TargetFramework)' != 'netstandard2.0'">
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
|
||||
<!-- CS0436: Allow ObsoleteAttribute to be redefined internally -->
|
||||
<NoWarn>$(NoWarn);CS0436</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\Common\Experimentals.cs" Link="Experimentals.cs" />
|
||||
<Compile Include="..\Common\McpHttpHeaders.cs" Link="McpHttpHeaders.cs" />
|
||||
<Compile Include="..\Common\McpProtocolVersions.cs" Link="McpProtocolVersions.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Exclude polyfills inherited from Directory.Build.props; this package only needs the ExperimentalAttribute polyfill. -->
|
||||
<ItemGroup>
|
||||
<Compile Remove="..\Common\Polyfills\**\*.cs" />
|
||||
<Compile Include="..\Common\Polyfills\System\Diagnostics\CodeAnalysis\ExperimentalAttribute.cs"
|
||||
Link="Polyfills\ExperimentalAttribute.cs" />
|
||||
<Compile Include="..\Common\Polyfills\System\Runtime\CompilerServices\CompilerFeatureRequiredAttribute.cs"
|
||||
Link="Polyfills\CompilerFeatureRequiredAttribute.cs" />
|
||||
<Compile Include="..\Common\Polyfills\System\Runtime\CompilerServices\IsExternalInit.cs"
|
||||
Link="Polyfills\IsExternalInit.cs" />
|
||||
<Compile Include="..\Common\Polyfills\System\Runtime\CompilerServices\RequiredMemberAttribute.cs"
|
||||
Link="Polyfills\RequiredMemberAttribute.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ModelContextProtocol\ModelContextProtocol.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\PACKAGE.md" Pack="true" PackagePath="\README.md" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.Extensions.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the parameters for a <c>tasks/cancel</c> request to signal intent to cancel an in-progress task.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Cancellation is cooperative: the request signals intent, and the server decides whether and when to honor it.
|
||||
/// A server is not obligated to actually stop the work; it is only obligated to acknowledge the request.
|
||||
/// Eventual transition to <see cref="McpTaskStatus.Cancelled"/> is not guaranteed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The <c>notifications/cancelled</c> notification must not be used for task cancellation.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// See the <see href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md">SEP-2663</see>
|
||||
/// specification for details.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class CancelTaskRequestParams : RequestParams
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the identifier of the task to cancel.
|
||||
/// </summary>
|
||||
[JsonPropertyName("taskId")]
|
||||
public required string TaskId { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.Extensions.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of a <c>tasks/cancel</c> request. This is an empty acknowledgement.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The server acknowledges the request with an empty result. Cancellation processing is
|
||||
/// eventually consistent — the task's observable status may remain <see cref="McpTaskStatus.Working"/>
|
||||
/// after the ack, and may ultimately reach a terminal status other than <see cref="McpTaskStatus.Cancelled"/>
|
||||
/// if the work finished before cancellation could take effect.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// See the <see href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md">SEP-2663</see>
|
||||
/// specification for details.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class CancelTaskResult : Result
|
||||
{
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.Extensions.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result returned by a server when it creates a task in lieu of a standard result.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A server returns <see cref="CreateTaskResult"/> instead of the standard result shape (e.g., <see cref="CallToolResult"/>)
|
||||
/// to indicate that the request will be processed asynchronously. The client then uses
|
||||
/// <see cref="TaskId"/> for subsequent <c>tasks/get</c>, <c>tasks/update</c>, and <c>tasks/cancel</c> calls.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A server must not return <see cref="CreateTaskResult"/> to a client that did not include the
|
||||
/// <c>io.modelcontextprotocol/tasks</c> extension capability on its request.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// See the <see href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md">SEP-2663</see>
|
||||
/// specification for details.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class CreateTaskResult : Result
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CreateTaskResult"/> class.
|
||||
/// </summary>
|
||||
public CreateTaskResult()
|
||||
{
|
||||
ResultType = "task";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the stable identifier for this task.
|
||||
/// </summary>
|
||||
[JsonPropertyName("taskId")]
|
||||
public required string TaskId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the current task status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public required McpTaskStatus Status { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional message describing the current task state.
|
||||
/// </summary>
|
||||
[JsonPropertyName("statusMessage")]
|
||||
public string? StatusMessage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the ISO 8601 timestamp when the task was created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the ISO 8601 timestamp when the task was last updated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("lastUpdatedAt")]
|
||||
public required DateTimeOffset LastUpdatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the time-to-live duration from creation, or <see langword="null"/> for unlimited.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ttlMs")]
|
||||
[JsonConverter(typeof(TimeSpanMillisecondsConverter))]
|
||||
public TimeSpan? TimeToLive { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the suggested polling interval in milliseconds.
|
||||
/// </summary>
|
||||
[JsonPropertyName("pollIntervalMs")]
|
||||
public long? PollIntervalMs { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.Extensions.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the parameters for a <c>tasks/get</c> request to poll for task completion.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Clients poll for task completion by sending <c>tasks/get</c> requests.
|
||||
/// Clients should respect the <see cref="GetTaskResult.PollIntervalMs"/> provided in responses
|
||||
/// when determining polling frequency.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// See the <see href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md">SEP-2663</see>
|
||||
/// specification for details.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class GetTaskRequestParams : RequestParams
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the identifier of the task to query.
|
||||
/// </summary>
|
||||
[JsonPropertyName("taskId")]
|
||||
public required string TaskId { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,450 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.Extensions.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of a <c>tasks/get</c> request, containing the full task state.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the abstract base for status-specific task results. The concrete type returned depends on the
|
||||
/// task's current <see cref="Status"/>:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="WorkingTaskResult"/> — <see cref="McpTaskStatus.Working"/></item>
|
||||
/// <item><see cref="CompletedTaskResult"/> — <see cref="McpTaskStatus.Completed"/></item>
|
||||
/// <item><see cref="FailedTaskResult"/> — <see cref="McpTaskStatus.Failed"/></item>
|
||||
/// <item><see cref="CancelledTaskResult"/> — <see cref="McpTaskStatus.Cancelled"/></item>
|
||||
/// <item><see cref="InputRequiredTaskResult"/> — <see cref="McpTaskStatus.InputRequired"/></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// See the <see href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md">SEP-2663</see>
|
||||
/// specification for details.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[JsonConverter(typeof(Converter))]
|
||||
public abstract class GetTaskResult : Result
|
||||
{
|
||||
/// <summary>Prevent external derivations.</summary>
|
||||
private protected GetTaskResult()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the stable identifier for this task.
|
||||
/// </summary>
|
||||
[JsonPropertyName("taskId")]
|
||||
public required string TaskId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the current task status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public abstract McpTaskStatus Status { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional message describing the current task state.
|
||||
/// </summary>
|
||||
[JsonPropertyName("statusMessage")]
|
||||
public string? StatusMessage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the ISO 8601 timestamp when the task was created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the ISO 8601 timestamp when the task was last updated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("lastUpdatedAt")]
|
||||
public required DateTimeOffset LastUpdatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the time-to-live duration from creation, or <see langword="null"/> for unlimited.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ttlMs")]
|
||||
public TimeSpan? TimeToLive { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the suggested polling interval in milliseconds.
|
||||
/// </summary>
|
||||
[JsonPropertyName("pollIntervalMs")]
|
||||
public long? PollIntervalMs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// JSON converter that deserializes <see cref="GetTaskResult"/> to the appropriate concrete subtype
|
||||
/// based on the <c>status</c> discriminator field.
|
||||
/// </summary>
|
||||
internal sealed class Converter : JsonConverter<GetTaskResult>
|
||||
{
|
||||
public override GetTaskResult? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType != JsonTokenType.StartObject)
|
||||
{
|
||||
throw new JsonException("Expected StartObject token for GetTaskResult.");
|
||||
}
|
||||
|
||||
string? taskId = null;
|
||||
string? statusString = null;
|
||||
string? statusMessage = null;
|
||||
DateTimeOffset? createdAt = null;
|
||||
DateTimeOffset? lastUpdatedAt = null;
|
||||
long? ttlMs = null;
|
||||
long? pollIntervalMs = null;
|
||||
string? resultType = null;
|
||||
JsonObject? meta = null;
|
||||
JsonElement? result = null;
|
||||
JsonElement? error = null;
|
||||
Dictionary<string, InputRequest>? inputRequests = null;
|
||||
|
||||
while (reader.Read())
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.EndObject)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (reader.TokenType != JsonTokenType.PropertyName)
|
||||
{
|
||||
throw new JsonException("Expected property name.");
|
||||
}
|
||||
|
||||
string propertyName = reader.GetString()!;
|
||||
reader.Read();
|
||||
|
||||
switch (propertyName)
|
||||
{
|
||||
case "taskId":
|
||||
taskId = reader.GetString();
|
||||
break;
|
||||
case "status":
|
||||
statusString = reader.GetString();
|
||||
break;
|
||||
case "statusMessage":
|
||||
statusMessage = reader.GetString();
|
||||
break;
|
||||
case "createdAt":
|
||||
createdAt = reader.GetDateTimeOffset();
|
||||
break;
|
||||
case "lastUpdatedAt":
|
||||
lastUpdatedAt = reader.GetDateTimeOffset();
|
||||
break;
|
||||
case "ttlMs":
|
||||
ttlMs = reader.GetInt64();
|
||||
break;
|
||||
case "pollIntervalMs":
|
||||
pollIntervalMs = reader.GetInt64();
|
||||
break;
|
||||
case "resultType":
|
||||
resultType = reader.GetString();
|
||||
break;
|
||||
case "_meta":
|
||||
meta = JsonSerializer.Deserialize(ref reader, options.GetTypeInfo<JsonObject>());
|
||||
break;
|
||||
case "result":
|
||||
result = JsonElement.ParseValue(ref reader);
|
||||
break;
|
||||
case "error":
|
||||
error = JsonElement.ParseValue(ref reader);
|
||||
break;
|
||||
case "inputRequests":
|
||||
if (reader.TokenType != JsonTokenType.StartObject)
|
||||
{
|
||||
throw new JsonException("'inputRequests' must be a JSON object.");
|
||||
}
|
||||
inputRequests = new Dictionary<string, InputRequest>(StringComparer.Ordinal);
|
||||
while (reader.Read())
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.EndObject)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (reader.TokenType != JsonTokenType.PropertyName)
|
||||
{
|
||||
throw new JsonException("Expected property name in 'inputRequests'.");
|
||||
}
|
||||
string requestKey = reader.GetString()!;
|
||||
reader.Read();
|
||||
var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.DefaultOptions.GetTypeInfo<InputRequest>())
|
||||
?? throw new JsonException($"Failed to deserialize InputRequest for key '{requestKey}'.");
|
||||
inputRequests[requestKey] = inputRequest;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
reader.Skip();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (taskId is null)
|
||||
{
|
||||
throw new JsonException("Missing required 'taskId' property on GetTaskResult.");
|
||||
}
|
||||
|
||||
if (statusString is null)
|
||||
{
|
||||
throw new JsonException("Missing required 'status' property on GetTaskResult.");
|
||||
}
|
||||
|
||||
if (createdAt is null)
|
||||
{
|
||||
throw new JsonException("Missing required 'createdAt' property on GetTaskResult.");
|
||||
}
|
||||
|
||||
if (lastUpdatedAt is null)
|
||||
{
|
||||
throw new JsonException("Missing required 'lastUpdatedAt' property on GetTaskResult.");
|
||||
}
|
||||
|
||||
GetTaskResult taskResult = statusString switch
|
||||
{
|
||||
"working" => new WorkingTaskResult
|
||||
{
|
||||
TaskId = taskId,
|
||||
CreatedAt = createdAt.Value,
|
||||
LastUpdatedAt = lastUpdatedAt.Value,
|
||||
},
|
||||
"completed" => result is not null
|
||||
? new CompletedTaskResult
|
||||
{
|
||||
TaskId = taskId,
|
||||
CreatedAt = createdAt.Value,
|
||||
LastUpdatedAt = lastUpdatedAt.Value,
|
||||
Result = result.Value,
|
||||
}
|
||||
: throw new JsonException("Completed task is missing required 'result' property."),
|
||||
"failed" => error is not null
|
||||
? new FailedTaskResult
|
||||
{
|
||||
TaskId = taskId,
|
||||
CreatedAt = createdAt.Value,
|
||||
LastUpdatedAt = lastUpdatedAt.Value,
|
||||
Error = error.Value,
|
||||
}
|
||||
: throw new JsonException("Failed task is missing required 'error' property."),
|
||||
"cancelled" => new CancelledTaskResult
|
||||
{
|
||||
TaskId = taskId,
|
||||
CreatedAt = createdAt.Value,
|
||||
LastUpdatedAt = lastUpdatedAt.Value,
|
||||
},
|
||||
"input_required" => inputRequests is not null
|
||||
? new InputRequiredTaskResult
|
||||
{
|
||||
TaskId = taskId,
|
||||
CreatedAt = createdAt.Value,
|
||||
LastUpdatedAt = lastUpdatedAt.Value,
|
||||
InputRequests = inputRequests,
|
||||
}
|
||||
: throw new JsonException("Input-required task is missing required 'inputRequests' property."),
|
||||
_ => throw new JsonException($"Unknown task status: '{statusString}'.")
|
||||
};
|
||||
|
||||
taskResult.StatusMessage = statusMessage;
|
||||
taskResult.TimeToLive = ttlMs is null ? null : TimeSpan.FromMilliseconds(ttlMs.Value);
|
||||
taskResult.PollIntervalMs = pollIntervalMs;
|
||||
taskResult.ResultType = resultType;
|
||||
taskResult.Meta = meta;
|
||||
|
||||
return taskResult;
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, GetTaskResult value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
|
||||
if (value.ResultType is not null)
|
||||
{
|
||||
writer.WriteString("resultType", value.ResultType);
|
||||
}
|
||||
|
||||
if (value.Meta is not null)
|
||||
{
|
||||
writer.WritePropertyName("_meta");
|
||||
JsonSerializer.Serialize(writer, value.Meta, options.GetTypeInfo<JsonObject>());
|
||||
}
|
||||
|
||||
writer.WriteString("taskId", value.TaskId);
|
||||
writer.WriteString("status", value.Status switch
|
||||
{
|
||||
McpTaskStatus.Working => "working",
|
||||
McpTaskStatus.Completed => "completed",
|
||||
McpTaskStatus.Failed => "failed",
|
||||
McpTaskStatus.Cancelled => "cancelled",
|
||||
McpTaskStatus.InputRequired => "input_required",
|
||||
_ => throw new JsonException($"Unknown McpTaskStatus: {value.Status}")
|
||||
});
|
||||
|
||||
if (value.StatusMessage is not null)
|
||||
{
|
||||
writer.WriteString("statusMessage", value.StatusMessage);
|
||||
}
|
||||
|
||||
writer.WriteString("createdAt", value.CreatedAt);
|
||||
writer.WriteString("lastUpdatedAt", value.LastUpdatedAt);
|
||||
|
||||
if (value.TimeToLive is not null)
|
||||
{
|
||||
writer.WriteNumber("ttlMs", (long)value.TimeToLive.Value.TotalMilliseconds);
|
||||
}
|
||||
|
||||
if (value.PollIntervalMs is not null)
|
||||
{
|
||||
writer.WriteNumber("pollIntervalMs", value.PollIntervalMs.Value);
|
||||
}
|
||||
|
||||
switch (value)
|
||||
{
|
||||
case CompletedTaskResult completed:
|
||||
writer.WritePropertyName("result");
|
||||
completed.Result.WriteTo(writer);
|
||||
break;
|
||||
case FailedTaskResult failed:
|
||||
writer.WritePropertyName("error");
|
||||
failed.Error.WriteTo(writer);
|
||||
break;
|
||||
case InputRequiredTaskResult inputRequired:
|
||||
writer.WritePropertyName("inputRequests");
|
||||
writer.WriteStartObject();
|
||||
if (inputRequired.InputRequests is { } reqs)
|
||||
{
|
||||
foreach (var kvp in reqs)
|
||||
{
|
||||
writer.WritePropertyName(kvp.Key);
|
||||
JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.DefaultOptions.GetTypeInfo<InputRequest>());
|
||||
}
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a task that is currently being processed by the server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// See the <see href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md">SEP-2663</see>
|
||||
/// specification for details.
|
||||
/// </remarks>
|
||||
public sealed class WorkingTaskResult : GetTaskResult
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
[JsonPropertyName("status")]
|
||||
public override McpTaskStatus Status => McpTaskStatus.Working;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a task that has completed successfully, carrying the final result.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The <see cref="Result"/> field contains the result structure matching the original request type.
|
||||
/// For example, a <c>tools/call</c> task would contain the <see cref="CallToolResult"/> structure.
|
||||
/// This includes tool calls that returned results with <c>isError: true</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// See the <see href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md">SEP-2663</see>
|
||||
/// specification for details.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class CompletedTaskResult : GetTaskResult
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
[JsonPropertyName("status")]
|
||||
public override McpTaskStatus Status => McpTaskStatus.Completed;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the final result of the task as raw JSON.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The structure matches the result type of the original request.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("result")]
|
||||
public required JsonElement Result { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a task that failed due to a JSON-RPC error during execution.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The <see cref="Error"/> field contains the JSON-RPC error object that caused the failure.
|
||||
/// This status must not be used for non-JSON-RPC errors.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// See the <see href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md">SEP-2663</see>
|
||||
/// specification for details.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class FailedTaskResult : GetTaskResult
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
[JsonPropertyName("status")]
|
||||
public override McpTaskStatus Status => McpTaskStatus.Failed;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the JSON-RPC error that caused the task to fail.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public required JsonElement Error { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a task that was cancelled before completion.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// See the <see href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md">SEP-2663</see>
|
||||
/// specification for details.
|
||||
/// </remarks>
|
||||
public sealed class CancelledTaskResult : GetTaskResult
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
[JsonPropertyName("status")]
|
||||
public override McpTaskStatus Status => McpTaskStatus.Cancelled;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a task that requires input from the client before it can proceed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The <see cref="InputRequests"/> field contains outstanding server-to-client requests
|
||||
/// that the client must fulfil. Each entry is keyed by an arbitrary identifier for matching
|
||||
/// requests to responses, and each value is an <see cref="InputRequest"/> wrapping the
|
||||
/// server-to-client request payload.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Clients must treat each entry as they would the equivalent standalone server-to-client request.
|
||||
/// Clients should deduplicate keys across consecutive polls to avoid presenting the same request
|
||||
/// to the user or model more than once.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// See the <see href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md">SEP-2663</see>
|
||||
/// specification for details.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class InputRequiredTaskResult : GetTaskResult
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
[JsonPropertyName("status")]
|
||||
public override McpTaskStatus Status => McpTaskStatus.InputRequired;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the server-to-client requests that need to be fulfilled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Keys are arbitrary identifiers for matching requests to responses.
|
||||
/// Each value is an <see cref="InputRequest"/> wrapping the server-to-client request
|
||||
/// (e.g., a sampling, elicitation, or roots-list request).
|
||||
/// </remarks>
|
||||
[JsonPropertyName("inputRequests")]
|
||||
public IDictionary<string, InputRequest>? InputRequests { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.Extensions.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the status of an MCP task.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Tasks are durable state machines that carry information about the underlying execution state
|
||||
/// of the request they augment. See the
|
||||
/// <see href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md">SEP-2663</see>
|
||||
/// specification for details.
|
||||
/// </remarks>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<McpTaskStatus>))]
|
||||
public enum McpTaskStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// The request is currently being processed.
|
||||
/// </summary>
|
||||
[JsonStringEnumMemberName("working")]
|
||||
Working,
|
||||
|
||||
/// <summary>
|
||||
/// The server needs input from the client before the task can proceed.
|
||||
/// The <c>tasks/get</c> response will include outstanding requests in the <c>inputRequests</c> field.
|
||||
/// </summary>
|
||||
[JsonStringEnumMemberName("input_required")]
|
||||
InputRequired,
|
||||
|
||||
/// <summary>
|
||||
/// The request completed successfully and results are available.
|
||||
/// This includes tool calls that returned results with <c>isError: true</c>.
|
||||
/// </summary>
|
||||
[JsonStringEnumMemberName("completed")]
|
||||
Completed,
|
||||
|
||||
/// <summary>
|
||||
/// The request was cancelled before completion.
|
||||
/// </summary>
|
||||
[JsonStringEnumMemberName("cancelled")]
|
||||
Cancelled,
|
||||
|
||||
/// <summary>
|
||||
/// The request failed due to a JSON-RPC error during execution.
|
||||
/// This status must not be used for non-JSON-RPC errors.
|
||||
/// </summary>
|
||||
[JsonStringEnumMemberName("failed")]
|
||||
Failed,
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
namespace ModelContextProtocol.Extensions.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of a request that supports task-augmented execution, which may be either
|
||||
/// the standard result or a <see cref="CreateTaskResult"/> indicating asynchronous processing.
|
||||
/// </summary>
|
||||
/// <typeparam name="TResult">The standard result type for the request (e.g., <see cref="CallToolResult"/>).</typeparam>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When a server supports the <c>io.modelcontextprotocol/tasks</c> extension and the client declares
|
||||
/// the extension capability on its request, the server may return a <see cref="CreateTaskResult"/>
|
||||
/// instead of the standard result. This type represents that polymorphic response.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Use <see cref="IsTask"/> to determine which variant was returned, then access either
|
||||
/// <see cref="Result"/> for the immediate result or <see cref="TaskCreated"/> for the task handle.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// See the <see href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md">SEP-2663</see>
|
||||
/// specification for details.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class ResultOrCreatedTask<TResult> where TResult : Result
|
||||
{
|
||||
private readonly TResult? _result;
|
||||
private readonly CreateTaskResult? _taskCreated;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="ResultOrCreatedTask{TResult}"/> with an immediate result.
|
||||
/// </summary>
|
||||
/// <param name="result">The standard result returned by the server.</param>
|
||||
public ResultOrCreatedTask(TResult result)
|
||||
{
|
||||
if (result is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(result));
|
||||
}
|
||||
_result = result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="ResultOrCreatedTask{TResult}"/> with a task handle.
|
||||
/// </summary>
|
||||
/// <param name="taskCreated">The task creation result returned by the server.</param>
|
||||
public ResultOrCreatedTask(CreateTaskResult taskCreated)
|
||||
{
|
||||
if (taskCreated is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(taskCreated));
|
||||
}
|
||||
_taskCreated = taskCreated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the server created a task instead of returning an immediate result.
|
||||
/// </summary>
|
||||
public bool IsTask => _taskCreated is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the immediate result, or <see langword="null"/> if the server created a task.
|
||||
/// </summary>
|
||||
public TResult? Result => _result;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the task creation result, or <see langword="null"/> if the server returned an immediate result.
|
||||
/// </summary>
|
||||
public CreateTaskResult? TaskCreated => _taskCreated;
|
||||
|
||||
/// <summary>
|
||||
/// Implicitly converts a <typeparamref name="TResult"/> to a <see cref="ResultOrCreatedTask{TResult}"/>
|
||||
/// wrapping the immediate result.
|
||||
/// </summary>
|
||||
/// <param name="result">The result to wrap.</param>
|
||||
public static implicit operator ResultOrCreatedTask<TResult>(TResult result) => new(result);
|
||||
|
||||
/// <summary>
|
||||
/// Implicitly converts a <see cref="CreateTaskResult"/> to a <see cref="ResultOrCreatedTask{TResult}"/>
|
||||
/// wrapping the task handle.
|
||||
/// </summary>
|
||||
/// <param name="taskCreated">The task creation result to wrap.</param>
|
||||
public static implicit operator ResultOrCreatedTask<TResult>(CreateTaskResult taskCreated) => new(taskCreated);
|
||||
}
|
||||
|
|
@ -0,0 +1,392 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.Extensions.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the parameters for a <c>notifications/tasks</c> notification sent by the server
|
||||
/// to push task status updates to the client.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each notification carries a complete task state for the current status, identical to what
|
||||
/// <c>tasks/get</c> would have returned at that moment. The concrete type depends on the task's
|
||||
/// current status:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="WorkingTaskNotificationParams"/> — <see cref="McpTaskStatus.Working"/></item>
|
||||
/// <item><see cref="CompletedTaskNotificationParams"/> — <see cref="McpTaskStatus.Completed"/></item>
|
||||
/// <item><see cref="FailedTaskNotificationParams"/> — <see cref="McpTaskStatus.Failed"/></item>
|
||||
/// <item><see cref="CancelledTaskNotificationParams"/> — <see cref="McpTaskStatus.Cancelled"/></item>
|
||||
/// <item><see cref="InputRequiredTaskNotificationParams"/> — <see cref="McpTaskStatus.InputRequired"/></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// To receive task status notifications, clients send a <c>subscriptions/listen</c> request
|
||||
/// including the task IDs they are interested in.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// See the <see href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md">SEP-2663</see>
|
||||
/// specification for details.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[JsonConverter(typeof(Converter))]
|
||||
public abstract class TaskStatusNotificationParams : NotificationParams
|
||||
{
|
||||
/// <summary>Prevent external derivations.</summary>
|
||||
private protected TaskStatusNotificationParams()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the stable identifier for this task.
|
||||
/// </summary>
|
||||
[JsonPropertyName("taskId")]
|
||||
public required string TaskId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the current task status.
|
||||
/// </summary>
|
||||
[JsonPropertyName("status")]
|
||||
public abstract McpTaskStatus Status { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional message describing the current task state.
|
||||
/// </summary>
|
||||
[JsonPropertyName("statusMessage")]
|
||||
public string? StatusMessage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the ISO 8601 timestamp when the task was created.
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public required DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the ISO 8601 timestamp when the task was last updated.
|
||||
/// </summary>
|
||||
[JsonPropertyName("lastUpdatedAt")]
|
||||
public required DateTimeOffset LastUpdatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the time-to-live duration from creation, or <see langword="null"/> for unlimited.
|
||||
/// </summary>
|
||||
[JsonPropertyName("ttlMs")]
|
||||
public TimeSpan? TimeToLive { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the suggested polling interval in milliseconds.
|
||||
/// </summary>
|
||||
[JsonPropertyName("pollIntervalMs")]
|
||||
public long? PollIntervalMs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// JSON converter that deserializes <see cref="TaskStatusNotificationParams"/> to the appropriate
|
||||
/// concrete subtype based on the <c>status</c> discriminator field.
|
||||
/// </summary>
|
||||
internal sealed class Converter : JsonConverter<TaskStatusNotificationParams>
|
||||
{
|
||||
public override TaskStatusNotificationParams? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType != JsonTokenType.StartObject)
|
||||
{
|
||||
throw new JsonException("Expected StartObject token for TaskStatusNotificationParams.");
|
||||
}
|
||||
|
||||
string? taskId = null;
|
||||
string? statusString = null;
|
||||
string? statusMessage = null;
|
||||
DateTimeOffset? createdAt = null;
|
||||
DateTimeOffset? lastUpdatedAt = null;
|
||||
long? ttlMs = null;
|
||||
long? pollIntervalMs = null;
|
||||
JsonObject? meta = null;
|
||||
JsonElement? result = null;
|
||||
JsonElement? error = null;
|
||||
Dictionary<string, InputRequest>? inputRequests = null;
|
||||
|
||||
while (reader.Read())
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.EndObject)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (reader.TokenType != JsonTokenType.PropertyName)
|
||||
{
|
||||
throw new JsonException("Expected property name.");
|
||||
}
|
||||
|
||||
string propertyName = reader.GetString()!;
|
||||
reader.Read();
|
||||
|
||||
switch (propertyName)
|
||||
{
|
||||
case "taskId":
|
||||
taskId = reader.GetString();
|
||||
break;
|
||||
case "status":
|
||||
statusString = reader.GetString();
|
||||
break;
|
||||
case "statusMessage":
|
||||
statusMessage = reader.GetString();
|
||||
break;
|
||||
case "createdAt":
|
||||
createdAt = reader.GetDateTimeOffset();
|
||||
break;
|
||||
case "lastUpdatedAt":
|
||||
lastUpdatedAt = reader.GetDateTimeOffset();
|
||||
break;
|
||||
case "ttlMs":
|
||||
ttlMs = reader.GetInt64();
|
||||
break;
|
||||
case "pollIntervalMs":
|
||||
pollIntervalMs = reader.GetInt64();
|
||||
break;
|
||||
case "_meta":
|
||||
meta = JsonSerializer.Deserialize(ref reader, options.GetTypeInfo<JsonObject>());
|
||||
break;
|
||||
case "result":
|
||||
result = JsonElement.ParseValue(ref reader);
|
||||
break;
|
||||
case "error":
|
||||
error = JsonElement.ParseValue(ref reader);
|
||||
break;
|
||||
case "inputRequests":
|
||||
if (reader.TokenType != JsonTokenType.StartObject)
|
||||
{
|
||||
throw new JsonException("'inputRequests' must be a JSON object.");
|
||||
}
|
||||
inputRequests = new Dictionary<string, InputRequest>(StringComparer.Ordinal);
|
||||
while (reader.Read())
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.EndObject)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (reader.TokenType != JsonTokenType.PropertyName)
|
||||
{
|
||||
throw new JsonException("Expected property name in 'inputRequests'.");
|
||||
}
|
||||
string requestKey = reader.GetString()!;
|
||||
reader.Read();
|
||||
var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.DefaultOptions.GetTypeInfo<InputRequest>())
|
||||
?? throw new JsonException($"Failed to deserialize InputRequest for key '{requestKey}'.");
|
||||
inputRequests[requestKey] = inputRequest;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
reader.Skip();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (taskId is null)
|
||||
{
|
||||
throw new JsonException("Missing required 'taskId' property on TaskStatusNotificationParams.");
|
||||
}
|
||||
|
||||
if (statusString is null)
|
||||
{
|
||||
throw new JsonException("Missing required 'status' property on TaskStatusNotificationParams.");
|
||||
}
|
||||
|
||||
if (createdAt is null)
|
||||
{
|
||||
throw new JsonException("Missing required 'createdAt' property on TaskStatusNotificationParams.");
|
||||
}
|
||||
|
||||
if (lastUpdatedAt is null)
|
||||
{
|
||||
throw new JsonException("Missing required 'lastUpdatedAt' property on TaskStatusNotificationParams.");
|
||||
}
|
||||
|
||||
TaskStatusNotificationParams notification = statusString switch
|
||||
{
|
||||
"working" => new WorkingTaskNotificationParams
|
||||
{
|
||||
TaskId = taskId,
|
||||
CreatedAt = createdAt.Value,
|
||||
LastUpdatedAt = lastUpdatedAt.Value,
|
||||
},
|
||||
"completed" => result is not null
|
||||
? new CompletedTaskNotificationParams
|
||||
{
|
||||
TaskId = taskId,
|
||||
CreatedAt = createdAt.Value,
|
||||
LastUpdatedAt = lastUpdatedAt.Value,
|
||||
Result = result.Value,
|
||||
}
|
||||
: throw new JsonException("Completed task notification is missing required 'result' property."),
|
||||
"failed" => error is not null
|
||||
? new FailedTaskNotificationParams
|
||||
{
|
||||
TaskId = taskId,
|
||||
CreatedAt = createdAt.Value,
|
||||
LastUpdatedAt = lastUpdatedAt.Value,
|
||||
Error = error.Value,
|
||||
}
|
||||
: throw new JsonException("Failed task notification is missing required 'error' property."),
|
||||
"cancelled" => new CancelledTaskNotificationParams
|
||||
{
|
||||
TaskId = taskId,
|
||||
CreatedAt = createdAt.Value,
|
||||
LastUpdatedAt = lastUpdatedAt.Value,
|
||||
},
|
||||
"input_required" => inputRequests is not null
|
||||
? new InputRequiredTaskNotificationParams
|
||||
{
|
||||
TaskId = taskId,
|
||||
CreatedAt = createdAt.Value,
|
||||
LastUpdatedAt = lastUpdatedAt.Value,
|
||||
InputRequests = inputRequests,
|
||||
}
|
||||
: throw new JsonException("Input-required task notification is missing required 'inputRequests' property."),
|
||||
_ => throw new JsonException($"Unknown task status: '{statusString}'.")
|
||||
};
|
||||
|
||||
notification.StatusMessage = statusMessage;
|
||||
notification.TimeToLive = ttlMs is null ? null : TimeSpan.FromMilliseconds(ttlMs.Value);
|
||||
notification.PollIntervalMs = pollIntervalMs;
|
||||
notification.Meta = meta;
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, TaskStatusNotificationParams value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
|
||||
if (value.Meta is not null)
|
||||
{
|
||||
writer.WritePropertyName("_meta");
|
||||
JsonSerializer.Serialize(writer, value.Meta, options.GetTypeInfo<JsonObject>());
|
||||
}
|
||||
|
||||
writer.WriteString("taskId", value.TaskId);
|
||||
writer.WriteString("status", value.Status switch
|
||||
{
|
||||
McpTaskStatus.Working => "working",
|
||||
McpTaskStatus.Completed => "completed",
|
||||
McpTaskStatus.Failed => "failed",
|
||||
McpTaskStatus.Cancelled => "cancelled",
|
||||
McpTaskStatus.InputRequired => "input_required",
|
||||
_ => throw new JsonException($"Unknown McpTaskStatus: {value.Status}")
|
||||
});
|
||||
|
||||
if (value.StatusMessage is not null)
|
||||
{
|
||||
writer.WriteString("statusMessage", value.StatusMessage);
|
||||
}
|
||||
|
||||
writer.WriteString("createdAt", value.CreatedAt);
|
||||
writer.WriteString("lastUpdatedAt", value.LastUpdatedAt);
|
||||
|
||||
if (value.TimeToLive is not null)
|
||||
{
|
||||
writer.WriteNumber("ttlMs", (long)value.TimeToLive.Value.TotalMilliseconds);
|
||||
}
|
||||
|
||||
if (value.PollIntervalMs is not null)
|
||||
{
|
||||
writer.WriteNumber("pollIntervalMs", value.PollIntervalMs.Value);
|
||||
}
|
||||
|
||||
switch (value)
|
||||
{
|
||||
case CompletedTaskNotificationParams completed:
|
||||
writer.WritePropertyName("result");
|
||||
completed.Result.WriteTo(writer);
|
||||
break;
|
||||
case FailedTaskNotificationParams failed:
|
||||
writer.WritePropertyName("error");
|
||||
failed.Error.WriteTo(writer);
|
||||
break;
|
||||
case InputRequiredTaskNotificationParams inputRequired:
|
||||
writer.WritePropertyName("inputRequests");
|
||||
writer.WriteStartObject();
|
||||
if (inputRequired.InputRequests is { } reqs)
|
||||
{
|
||||
foreach (var kvp in reqs)
|
||||
{
|
||||
writer.WritePropertyName(kvp.Key);
|
||||
JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.DefaultOptions.GetTypeInfo<InputRequest>());
|
||||
}
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
break;
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task notification for a task that is currently being processed.
|
||||
/// </summary>
|
||||
public sealed class WorkingTaskNotificationParams : TaskStatusNotificationParams
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override McpTaskStatus Status => McpTaskStatus.Working;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task notification for a task that has completed successfully.
|
||||
/// </summary>
|
||||
public sealed class CompletedTaskNotificationParams : TaskStatusNotificationParams
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override McpTaskStatus Status => McpTaskStatus.Completed;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the final result of the task.
|
||||
/// </summary>
|
||||
[JsonPropertyName("result")]
|
||||
public required JsonElement Result { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task notification for a task that failed.
|
||||
/// </summary>
|
||||
public sealed class FailedTaskNotificationParams : TaskStatusNotificationParams
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override McpTaskStatus Status => McpTaskStatus.Failed;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the JSON-RPC error that caused the task to fail.
|
||||
/// </summary>
|
||||
[JsonPropertyName("error")]
|
||||
public required JsonElement Error { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task notification for a task that was cancelled.
|
||||
/// </summary>
|
||||
public sealed class CancelledTaskNotificationParams : TaskStatusNotificationParams
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override McpTaskStatus Status => McpTaskStatus.Cancelled;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task notification for a task that requires input from the client.
|
||||
/// </summary>
|
||||
public sealed class InputRequiredTaskNotificationParams : TaskStatusNotificationParams
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override McpTaskStatus Status => McpTaskStatus.InputRequired;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the server-to-client requests that need to be fulfilled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Keys are arbitrary identifiers for matching requests to responses. Each value is an
|
||||
/// <see cref="InputRequest"/> wrapping the server-to-client request payload, matching
|
||||
/// the typed format defined by the Multi Round-Trip Requests (MRTR) extension (SEP-2322).
|
||||
/// </remarks>
|
||||
[JsonPropertyName("inputRequests")]
|
||||
public IDictionary<string, InputRequest>? InputRequests { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.Extensions.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the parameters for a <c>tasks/update</c> request to provide input responses
|
||||
/// to outstanding server-to-client requests on a task.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When a task requires input from the client (indicated by <see cref="McpTaskStatus.InputRequired"/>),
|
||||
/// the server includes outstanding requests in the <c>inputRequests</c> field of the <c>tasks/get</c> response.
|
||||
/// The client provides responses via the inherited <see cref="RequestParams.InputResponses"/> field in
|
||||
/// <c>tasks/update</c> requests; the wire format matches the typed envelope defined by the Multi Round-Trip
|
||||
/// Requests (MRTR) extension (SEP-2322).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// See the <see href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md">SEP-2663</see>
|
||||
/// specification for details.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class UpdateTaskRequestParams : RequestParams
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the identifier of the task to update.
|
||||
/// </summary>
|
||||
[JsonPropertyName("taskId")]
|
||||
public required string TaskId { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.Extensions.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of a <c>tasks/update</c> request. This is an empty acknowledgement.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// On success, the server acknowledges the request with an empty result.
|
||||
/// The acknowledgement is eventually consistent: the server may accept the responses and
|
||||
/// return the ack before the task's observable status reflects them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// See the <see href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md">SEP-2663</see>
|
||||
/// specification for details.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class UpdateTaskResult : Result
|
||||
{
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ModelContextProtocol.Extensions.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an interface for storing and managing the lifecycle of MCP tasks.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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>
|
||||
/// 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>
|
||||
public interface IMcpTaskStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new task for tracking an asynchronous execution.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>
|
||||
/// A <see cref="McpTaskInfo"/> with a unique task ID, initial status of <see cref="McpTaskStatus.Working"/>,
|
||||
/// and timing metadata (TTL, poll interval).
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// <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<McpTaskInfo> CreateTaskAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the current state of a task.
|
||||
/// </summary>
|
||||
/// <param name="taskId">The unique identifier of the task to retrieve.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>
|
||||
/// A <see cref="McpTaskInfo"/> representing the current task state,
|
||||
/// or <see langword="null"/> if the task does not exist.
|
||||
/// </returns>
|
||||
Task<McpTaskInfo?> GetTaskAsync(string taskId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 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="result">The serialized result payload.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
Task SetCompletedAsync(string taskId, JsonElement result, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 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="error">The serialized error information.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
Task SetFailedAsync(string taskId, JsonElement error, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Transitions the task to <see cref="McpTaskStatus.Cancelled"/>.
|
||||
/// </summary>
|
||||
/// <param name="taskId">The unique identifier of the task to cancel.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>
|
||||
/// <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>
|
||||
/// 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>
|
||||
/// 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 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);
|
||||
}
|
||||
|
|
@ -0,0 +1,274 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ModelContextProtocol.Extensions.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an in-memory implementation of <see cref="IMcpTaskStore"/> for development and testing scenarios.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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>
|
||||
/// Tasks created with a <see cref="DefaultTimeToLive"/> are discarded once their time-to-live
|
||||
/// elapses (as permitted by SEP-2663): an expired task is removed on access, and an opportunistic
|
||||
/// throttled sweep reclaims expired tasks that are never polled again. Tasks created without a
|
||||
/// time-to-live are retained until the process exits.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// For production scenarios requiring durability, session isolation, or more advanced retention
|
||||
/// policies, implement a custom <see cref="IMcpTaskStore"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class InMemoryMcpTaskStore : IMcpTaskStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, McpTaskInfo> _tasks = new();
|
||||
private static readonly long s_sweepIntervalTicks = TimeSpan.FromSeconds(30).Ticks;
|
||||
private long _lastSweepTicks = DateTimeOffset.UtcNow.UtcTicks;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default poll interval in milliseconds for new tasks.
|
||||
/// </summary>
|
||||
/// <value>The default is 1000 milliseconds.</value>
|
||||
public long DefaultPollIntervalMs { get; set; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default time-to-live for new tasks, or <see langword="null"/> for unlimited.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When set to a positive value, tasks are discarded once this duration elapses from their
|
||||
/// creation. A <see langword="null"/> or non-positive value keeps tasks until the process exits.
|
||||
/// </remarks>
|
||||
public TimeSpan? DefaultTimeToLive { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<McpTaskInfo> CreateTaskAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
SweepExpired(now);
|
||||
|
||||
var taskId = Guid.NewGuid().ToString("N");
|
||||
|
||||
var info = new McpTaskInfo(taskId, McpTaskStatus.Working, now, now, DefaultTimeToLive, DefaultPollIntervalMs);
|
||||
_tasks[taskId] = info;
|
||||
|
||||
return Task.FromResult(info);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<McpTaskInfo?> GetTaskAsync(string taskId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (_tasks.TryGetValue(taskId, out var info))
|
||||
{
|
||||
if (IsExpired(info, now))
|
||||
{
|
||||
_tasks.TryRemove(taskId, out _);
|
||||
return Task.FromResult<McpTaskInfo?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<McpTaskInfo?>(info);
|
||||
}
|
||||
|
||||
return Task.FromResult<McpTaskInfo?>(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task SetCompletedAsync(string taskId, JsonElement result, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Update(taskId, entry => IsTerminal(entry.Status)
|
||||
? entry
|
||||
: entry with
|
||||
{
|
||||
Status = McpTaskStatus.Completed,
|
||||
Result = result,
|
||||
LastUpdatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task SetFailedAsync(string taskId, JsonElement error, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Update(taskId, entry => IsTerminal(entry.Status)
|
||||
? entry
|
||||
: entry with
|
||||
{
|
||||
Status = McpTaskStatus.Failed,
|
||||
Error = error,
|
||||
LastUpdatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<bool> SetCancelledAsync(string taskId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_tasks.TryGetValue(taskId, out var entry) || IsTerminal(entry.Status))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
bool transitioned = false;
|
||||
Update(taskId, e =>
|
||||
{
|
||||
if (IsTerminal(e.Status))
|
||||
{
|
||||
return e;
|
||||
}
|
||||
|
||||
transitioned = true;
|
||||
return e with { Status = McpTaskStatus.Cancelled, LastUpdatedAt = DateTimeOffset.UtcNow };
|
||||
});
|
||||
|
||||
return Task.FromResult(transitioned);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event Action<InputResponseReceivedEventArgs>? InputResponseReceived;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task ResolveInputRequestsAsync(
|
||||
string taskId,
|
||||
IDictionary<string, InputResponse> inputResponses,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
bool wasTerminal = false;
|
||||
Update(taskId, entry =>
|
||||
{
|
||||
if (IsTerminal(entry.Status))
|
||||
{
|
||||
wasTerminal = true;
|
||||
return entry;
|
||||
}
|
||||
|
||||
var requests = entry.InputRequests as ImmutableDictionary<string, InputRequest>
|
||||
?? entry.InputRequests?.ToImmutableDictionary()
|
||||
?? ImmutableDictionary<string, InputRequest>.Empty;
|
||||
|
||||
foreach (var key in inputResponses.Keys)
|
||||
{
|
||||
requests = requests.Remove(key);
|
||||
}
|
||||
|
||||
var status = requests.IsEmpty ? McpTaskStatus.Working : entry.Status;
|
||||
|
||||
return entry with
|
||||
{
|
||||
InputRequests = requests,
|
||||
Status = status,
|
||||
LastUpdatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
});
|
||||
|
||||
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
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private static bool IsTerminal(McpTaskStatus status) =>
|
||||
status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled;
|
||||
|
||||
private static bool IsExpired(McpTaskInfo info, DateTimeOffset now) =>
|
||||
info.TimeToLive is { } ttl && ttl > TimeSpan.Zero && now - info.CreatedAt >= ttl;
|
||||
|
||||
private void SweepExpired(DateTimeOffset now)
|
||||
{
|
||||
long last = Interlocked.Read(ref _lastSweepTicks);
|
||||
if (now.UtcTicks - last < s_sweepIntervalTicks)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure only one caller runs the sweep per interval; concurrent callers skip it.
|
||||
if (Interlocked.CompareExchange(ref _lastSweepTicks, now.UtcTicks, last) != last)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var kvp in _tasks)
|
||||
{
|
||||
if (IsExpired(kvp.Value, now))
|
||||
{
|
||||
// TaskId values are unique GUIDs that are never reused, and CreatedAt/TimeToLive
|
||||
// are immutable after creation, so an entry judged expired here stays expired.
|
||||
// Removing by key is therefore safe even if the value was concurrently updated.
|
||||
_tasks.TryRemove(kvp.Key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Update(string taskId, Func<McpTaskInfo, McpTaskInfo> transform)
|
||||
{
|
||||
SpinWait spin = default;
|
||||
while (true)
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace ModelContextProtocol.Extensions.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Provides data for the <see cref="IMcpTaskStore.InputResponseReceived"/> event.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ModelContextProtocol.Extensions.Tasks;
|
||||
|
||||
/// <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>
|
||||
public sealed record McpTaskInfo(
|
||||
string TaskId,
|
||||
McpTaskStatus Status,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset LastUpdatedAt,
|
||||
TimeSpan? TimeToLive = null,
|
||||
long? PollIntervalMs = null,
|
||||
string? StatusMessage = null,
|
||||
JsonElement? Result = null,
|
||||
JsonElement? Error = null,
|
||||
IReadOnlyDictionary<string, InputRequest>? InputRequests = null);
|
||||
|
|
@ -0,0 +1,334 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ModelContextProtocol;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ModelContextProtocol.Extensions.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="IMcpServerBuilder"/> to enable MCP Tasks support.
|
||||
/// </summary>
|
||||
public static class McpTasksBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enables MCP Tasks support backed by the specified task store.
|
||||
/// </summary>
|
||||
/// <param name="builder">The server builder.</param>
|
||||
/// <param name="store">The task store.</param>
|
||||
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
|
||||
public static IMcpServerBuilder WithTasks(this IMcpServerBuilder builder, IMcpTaskStore store)
|
||||
{
|
||||
#if NET
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ArgumentNullException.ThrowIfNull(store);
|
||||
#else
|
||||
if (builder is null) throw new ArgumentNullException(nameof(builder));
|
||||
if (store is null) throw new ArgumentNullException(nameof(store));
|
||||
#endif
|
||||
|
||||
// Resolve ILoggerFactory from the provider (rather than requiring the caller to pass one) so the
|
||||
// background task body has somewhere to report failures. It is optional: if no logging is
|
||||
// registered, the options fall back to NullLoggerFactory.
|
||||
builder.Services.AddSingleton<IPostConfigureOptions<McpServerOptions>>(
|
||||
sp => new McpTasksPostConfigureOptions(store, sp.GetService<ILoggerFactory>()));
|
||||
return builder;
|
||||
}
|
||||
|
||||
private sealed class McpTasksPostConfigureOptions(IMcpTaskStore store, ILoggerFactory? loggerFactory) : IPostConfigureOptions<McpServerOptions>
|
||||
{
|
||||
private readonly IMcpTaskStore _store = store;
|
||||
private readonly ILogger _logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<McpTasksPostConfigureOptions>();
|
||||
private readonly ConcurrentDictionary<string, CancellationTokenSource> _cancellationSources = new(StringComparer.Ordinal);
|
||||
|
||||
public void PostConfigure(string? name, McpServerOptions options)
|
||||
{
|
||||
#if NET
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
#else
|
||||
if (options is null) throw new ArgumentNullException(nameof(options));
|
||||
#endif
|
||||
|
||||
options.Capabilities ??= new ServerCapabilities();
|
||||
options.Capabilities.Extensions ??= new Dictionary<string, object>();
|
||||
if (!options.Capabilities.Extensions.ContainsKey(TasksProtocol.ExtensionId))
|
||||
{
|
||||
options.Capabilities.Extensions[TasksProtocol.ExtensionId] = new JsonObject();
|
||||
}
|
||||
|
||||
options.RequestHandlers ??= new List<McpServerRequestHandler>();
|
||||
options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksGet, Handler = HandleGetTask });
|
||||
options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksUpdate, Handler = HandleUpdateTask });
|
||||
options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksCancel, Handler = HandleCancelTask });
|
||||
|
||||
// Use a filter rather than a handler so it wraps around Core's tool dispatch.
|
||||
// This ensures it intercepts tool calls BEFORE the tool is invoked, allowing
|
||||
// it to spawn background execution and return the task alternate immediately.
|
||||
options.Filters.Request.CallToolWithAlternateFilters.Add(next => async (request, cancellationToken) =>
|
||||
{
|
||||
if (IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest) && HasTaskExtensionOptIn(request.Params?.Meta))
|
||||
{
|
||||
var taskInfo = await _store.CreateTaskAsync(cancellationToken).ConfigureAwait(false);
|
||||
var taskId = taskInfo.TaskId;
|
||||
var cts = new CancellationTokenSource();
|
||||
_cancellationSources[taskId] = cts;
|
||||
var taskCancellationToken = cts.Token;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using (McpTasksServerExtensions.CreateMcpTaskScope(request.Server, taskId, _store))
|
||||
{
|
||||
try
|
||||
{
|
||||
var augmented = await next(request, taskCancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (augmented.IsAlternate)
|
||||
{
|
||||
var error = new JsonRpcErrorDetail
|
||||
{
|
||||
Code = (int)McpErrorCode.InternalError,
|
||||
Message = $"{nameof(IMcpTaskStore)} is configured and the {nameof(McpServerHandlers.CallToolWithAlternateHandler)} returned IsAlternate = true. Use only one mechanism.",
|
||||
};
|
||||
var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo<JsonRpcErrorDetail>());
|
||||
await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var resultJson = JsonSerializer.SerializeToElement(augmented.Result!, McpJsonUtilities.DefaultOptions.GetTypeInfo<CallToolResult>());
|
||||
await _store.SetCompletedAsync(taskId, resultJson).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (taskCancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await _store.SetCancelledAsync(taskId, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (InputRequiredException)
|
||||
{
|
||||
var error = new JsonRpcErrorDetail
|
||||
{
|
||||
Code = (int)McpErrorCode.InvalidRequest,
|
||||
Message = "MRTR and tasks cannot be composed via [McpServerTool] yet.",
|
||||
};
|
||||
var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo<JsonRpcErrorDetail>());
|
||||
await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false);
|
||||
}
|
||||
catch (McpProtocolException mcpEx)
|
||||
{
|
||||
// SEP-2663 §186: protocol exceptions store as failed with JSON-RPC error shape.
|
||||
var error = new JsonRpcErrorDetail { Code = (int)mcpEx.ErrorCode, Message = mcpEx.Message };
|
||||
var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo<JsonRpcErrorDetail>());
|
||||
await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Non-protocol exceptions are wrapped as CallToolResult { IsError = true },
|
||||
// matching Core's BuildInitialAlternateToolFilter behavior.
|
||||
var errorResult = new CallToolResult
|
||||
{
|
||||
IsError = true,
|
||||
Content = [new TextContentBlock
|
||||
{
|
||||
Text = ex is McpException
|
||||
? $"An error occurred invoking '{request.Params?.Name}': {ex.Message}"
|
||||
: $"An error occurred invoking '{request.Params?.Name}'.",
|
||||
}],
|
||||
};
|
||||
var resultJson = JsonSerializer.SerializeToElement(errorResult, McpJsonUtilities.DefaultOptions.GetTypeInfo<CallToolResult>());
|
||||
await _store.SetCompletedAsync(taskId, resultJson).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_cancellationSources.TryRemove(taskId, out var registeredCts))
|
||||
{
|
||||
registeredCts.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception outer)
|
||||
{
|
||||
// The inner handlers above record every expected outcome. Reaching here means a
|
||||
// store operation inside one of those handlers (or the task scope) threw, most
|
||||
// likely from a custom IMcpTaskStore. Record the failure best-effort and never let
|
||||
// it surface as an unobserved task exception.
|
||||
_logger.LogError(outer, "Background execution of task '{TaskId}' terminated unexpectedly while recording its result.", taskId);
|
||||
|
||||
try
|
||||
{
|
||||
var error = new JsonRpcErrorDetail { Code = (int)McpErrorCode.InternalError, Message = outer.Message };
|
||||
var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo<JsonRpcErrorDetail>());
|
||||
await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception storeEx)
|
||||
{
|
||||
_logger.LogError(storeEx, "Failed to record the failure of background task '{TaskId}'.", taskId);
|
||||
}
|
||||
|
||||
if (_cancellationSources.TryRemove(taskId, out var leftoverCts))
|
||||
{
|
||||
leftoverCts.Dispose();
|
||||
}
|
||||
}
|
||||
}, CancellationToken.None);
|
||||
|
||||
return ResultOrAlternate<CallToolResult>.FromAlternate(
|
||||
ToCreateTaskResult(taskInfo),
|
||||
McpTasksJsonContext.Default.CreateTaskResult);
|
||||
}
|
||||
|
||||
return await next(request, cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
}
|
||||
|
||||
private async ValueTask<JsonNode?> HandleGetTask(JsonRpcRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksGet);
|
||||
|
||||
var requestParams = request.Params?.Deserialize(McpTasksJsonContext.Default.GetTaskRequestParams)
|
||||
?? throw new McpProtocolException("Missing params for tasks/get", McpErrorCode.InvalidParams);
|
||||
|
||||
var info = await _store.GetTaskAsync(requestParams.TaskId, cancellationToken).ConfigureAwait(false);
|
||||
if (info is null)
|
||||
{
|
||||
throw new McpProtocolException($"Unknown task: '{requestParams.TaskId}'", McpErrorCode.InvalidParams);
|
||||
}
|
||||
|
||||
return JsonSerializer.SerializeToNode(ToGetTaskResult(info), McpTasksJsonContext.Default.GetTaskResult);
|
||||
}
|
||||
|
||||
private async ValueTask<JsonNode?> HandleUpdateTask(JsonRpcRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksUpdate);
|
||||
|
||||
var taskId = request.Params?["taskId"]?.GetValue<string>()
|
||||
?? throw new McpProtocolException("Missing params.taskId for tasks/update", McpErrorCode.InvalidParams);
|
||||
|
||||
// Deserialize inputResponses using Core's options which can access the internal
|
||||
// InputResponsesCore backing property on RequestParams. The extension's source-gen
|
||||
// context cannot see that internal member.
|
||||
var inputResponses = request.Params?["inputResponses"]?.Deserialize(
|
||||
McpJsonUtilities.DefaultOptions.GetTypeInfo<IDictionary<string, InputResponse>>())
|
||||
?? new Dictionary<string, InputResponse>();
|
||||
|
||||
await _store.ResolveInputRequestsAsync(taskId, inputResponses, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return JsonSerializer.SerializeToNode(new UpdateTaskResult(), McpTasksJsonContext.Default.UpdateTaskResult);
|
||||
}
|
||||
|
||||
private async ValueTask<JsonNode?> HandleCancelTask(JsonRpcRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksCancel);
|
||||
|
||||
var requestParams = request.Params?.Deserialize(McpTasksJsonContext.Default.CancelTaskRequestParams)
|
||||
?? throw new McpProtocolException("Missing params for tasks/cancel", McpErrorCode.InvalidParams);
|
||||
|
||||
await _store.SetCancelledAsync(requestParams.TaskId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (_cancellationSources.TryRemove(requestParams.TaskId, out var cts))
|
||||
{
|
||||
cts.Cancel();
|
||||
cts.Dispose();
|
||||
}
|
||||
|
||||
return JsonSerializer.SerializeToNode(new CancelTaskResult(), McpTasksJsonContext.Default.CancelTaskResult);
|
||||
}
|
||||
|
||||
private static void GateToJuly2026OrLaterProtocol(JsonRpcRequest request, string method)
|
||||
{
|
||||
if (!IsJuly2026OrLaterProtocolRequest(request))
|
||||
{
|
||||
throw new McpProtocolException(
|
||||
$"The method '{method}' requires a newer protocol revision that supports tasks " +
|
||||
$"(the '{McpProtocolVersions.July2026ProtocolVersion}' revision or later); " +
|
||||
$"the negotiated protocol version is '{request?.Context?.ProtocolVersion ?? "(none)"}'.",
|
||||
McpErrorCode.MethodNotFound);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasTaskExtensionOptIn(JsonObject? meta) =>
|
||||
meta is not null &&
|
||||
meta[MetaKeys.ClientCapabilities] is JsonObject caps &&
|
||||
caps["extensions"] is JsonObject exts &&
|
||||
exts.ContainsKey(TasksProtocol.ExtensionId);
|
||||
|
||||
private static bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request) =>
|
||||
McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(request?.Context?.ProtocolVersion);
|
||||
|
||||
private static CreateTaskResult ToCreateTaskResult(McpTaskInfo info) => new()
|
||||
{
|
||||
TaskId = info.TaskId,
|
||||
Status = info.Status,
|
||||
CreatedAt = info.CreatedAt,
|
||||
LastUpdatedAt = info.LastUpdatedAt,
|
||||
TimeToLive = info.TimeToLive,
|
||||
PollIntervalMs = info.PollIntervalMs,
|
||||
StatusMessage = info.StatusMessage,
|
||||
};
|
||||
|
||||
private static GetTaskResult ToGetTaskResult(McpTaskInfo info) => info.Status switch
|
||||
{
|
||||
McpTaskStatus.Working => new WorkingTaskResult
|
||||
{
|
||||
TaskId = info.TaskId,
|
||||
CreatedAt = info.CreatedAt,
|
||||
LastUpdatedAt = info.LastUpdatedAt,
|
||||
TimeToLive = info.TimeToLive,
|
||||
PollIntervalMs = info.PollIntervalMs,
|
||||
StatusMessage = info.StatusMessage,
|
||||
ResultType = "complete",
|
||||
},
|
||||
McpTaskStatus.Completed => new CompletedTaskResult
|
||||
{
|
||||
TaskId = info.TaskId,
|
||||
CreatedAt = info.CreatedAt,
|
||||
LastUpdatedAt = info.LastUpdatedAt,
|
||||
TimeToLive = info.TimeToLive,
|
||||
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
|
||||
{
|
||||
TaskId = info.TaskId,
|
||||
CreatedAt = info.CreatedAt,
|
||||
LastUpdatedAt = info.LastUpdatedAt,
|
||||
TimeToLive = info.TimeToLive,
|
||||
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
|
||||
{
|
||||
TaskId = info.TaskId,
|
||||
CreatedAt = info.CreatedAt,
|
||||
LastUpdatedAt = info.LastUpdatedAt,
|
||||
TimeToLive = info.TimeToLive,
|
||||
PollIntervalMs = info.PollIntervalMs,
|
||||
StatusMessage = info.StatusMessage,
|
||||
ResultType = "complete",
|
||||
},
|
||||
McpTaskStatus.InputRequired => new InputRequiredTaskResult
|
||||
{
|
||||
TaskId = info.TaskId,
|
||||
CreatedAt = info.CreatedAt,
|
||||
LastUpdatedAt = info.LastUpdatedAt,
|
||||
TimeToLive = info.TimeToLive,
|
||||
PollIntervalMs = info.PollIntervalMs,
|
||||
StatusMessage = info.StatusMessage,
|
||||
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}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ModelContextProtocol.Extensions.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for task-aware server operations.
|
||||
/// </summary>
|
||||
public static class McpTasksServerExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends a task status notification to the connected client.
|
||||
/// </summary>
|
||||
/// <param name="server">The server sending the notification.</param>
|
||||
/// <param name="notificationParams">The notification payload.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task representing the send operation.</returns>
|
||||
public static Task SendTaskStatusNotificationAsync(
|
||||
this McpServer server,
|
||||
TaskStatusNotificationParams notificationParams,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
#if NET
|
||||
ArgumentNullException.ThrowIfNull(server);
|
||||
ArgumentNullException.ThrowIfNull(notificationParams);
|
||||
#else
|
||||
if (server is null) throw new ArgumentNullException(nameof(server));
|
||||
if (notificationParams is null) throw new ArgumentNullException(nameof(notificationParams));
|
||||
#endif
|
||||
|
||||
return server.SendNotificationAsync(
|
||||
TasksProtocol.NotificationTaskStatus,
|
||||
notificationParams,
|
||||
McpTasksJsonContext.Default.Options,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a scope that routes server-initiated requests through the specified task store.
|
||||
/// </summary>
|
||||
/// <param name="server">The server whose outgoing requests should be redirected.</param>
|
||||
/// <param name="taskId">The related task identifier.</param>
|
||||
/// <param name="store">The task store used to surface input requests.</param>
|
||||
/// <returns>An <see cref="IDisposable"/> that restores the previous outgoing-request behavior.</returns>
|
||||
public static IDisposable CreateMcpTaskScope(this McpServer server, string taskId, IMcpTaskStore store)
|
||||
{
|
||||
#if NET
|
||||
ArgumentNullException.ThrowIfNull(server);
|
||||
ArgumentNullException.ThrowIfNull(taskId);
|
||||
ArgumentNullException.ThrowIfNull(store);
|
||||
#else
|
||||
if (server is null) throw new ArgumentNullException(nameof(server));
|
||||
if (taskId is null) throw new ArgumentNullException(nameof(taskId));
|
||||
if (store is null) throw new ArgumentNullException(nameof(store));
|
||||
#endif
|
||||
|
||||
return server.InterceptOutgoingRequests(async (method, paramsNode, cancellationToken) =>
|
||||
{
|
||||
var requestId = Guid.NewGuid().ToString("N");
|
||||
|
||||
var inputRequest = new InputRequest
|
||||
{
|
||||
Method = method,
|
||||
Params = paramsNode is null
|
||||
? default
|
||||
: JsonSerializer.SerializeToElement(paramsNode, McpJsonUtilities.DefaultOptions.GetTypeInfo<JsonNode>()),
|
||||
};
|
||||
|
||||
var tcs = new TaskCompletionSource<InputResponse>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
void handler(InputResponseReceivedEventArgs args)
|
||||
{
|
||||
if (args.TaskId == taskId && args.RequestId == requestId)
|
||||
{
|
||||
tcs.TrySetResult(args.Response);
|
||||
}
|
||||
}
|
||||
|
||||
store.InputResponseReceived += handler;
|
||||
try
|
||||
{
|
||||
await store.SetInputRequestsAsync(
|
||||
taskId,
|
||||
new Dictionary<string, InputRequest> { [requestId] = inputRequest },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
#if NET
|
||||
var response = await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
#else
|
||||
using (cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken)))
|
||||
{
|
||||
var response = await tcs.Task.ConfigureAwait(false);
|
||||
return JsonNode.Parse(response.RawValue.GetRawText());
|
||||
}
|
||||
#endif
|
||||
|
||||
#if NET
|
||||
return JsonNode.Parse(response.RawValue.GetRawText());
|
||||
#endif
|
||||
}
|
||||
finally
|
||||
{
|
||||
store.InputResponseReceived -= handler;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
37
src/ModelContextProtocol.Extensions.Tasks/TasksProtocol.cs
Normal file
37
src/ModelContextProtocol.Extensions.Tasks/TasksProtocol.cs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
namespace ModelContextProtocol.Extensions.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Provides constants for the MCP Tasks extension (SEP-2663).
|
||||
/// </summary>
|
||||
public static class TasksProtocol
|
||||
{
|
||||
/// <summary>
|
||||
/// The extension identifier for the MCP Tasks extension.
|
||||
/// </summary>
|
||||
public const string ExtensionId = "io.modelcontextprotocol/tasks";
|
||||
|
||||
/// <summary>
|
||||
/// The name of the request method sent from the client to poll for task completion.
|
||||
/// </summary>
|
||||
public const string MethodTasksGet = "tasks/get";
|
||||
|
||||
/// <summary>
|
||||
/// The name of the request method sent from the client to provide input responses to a task.
|
||||
/// </summary>
|
||||
public const string MethodTasksUpdate = "tasks/update";
|
||||
|
||||
/// <summary>
|
||||
/// The name of the request method sent from the client to signal intent to cancel a task.
|
||||
/// </summary>
|
||||
public const string MethodTasksCancel = "tasks/cancel";
|
||||
|
||||
/// <summary>
|
||||
/// The name of the notification sent by the server when a task's status changes.
|
||||
/// </summary>
|
||||
public const string NotificationTaskStatus = "notifications/tasks/status";
|
||||
|
||||
/// <summary>
|
||||
/// The metadata key used to associate requests, responses, and notifications with a task.
|
||||
/// </summary>
|
||||
public const string MetaRelatedTask = "io.modelcontextprotocol/related-task";
|
||||
}
|
||||
Loading…
Reference in a new issue