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:
Jeff Handley 2026-07-14 15:53:58 -07:00 • committed by GitHub
commit 2ca96f548b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2559 additions and 1895 deletions

View file

@ -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)"}'.");
}
}
}

View file

@ -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
{
}

View file

@ -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>

View file

@ -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; }
}

View file

@ -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
{
}

View file

@ -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; }
}

View file

@ -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; }
}

View file

@ -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; }
}

View file

@ -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,
}

View file

@ -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);
}

View file

@ -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; }
}

View file

@ -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; }
}

View file

@ -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
{
}

View file

@ -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);
}

View file

@ -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();
}
}
}

View file

@ -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; }
}

View file

@ -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);

View file

@ -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}"),
};
}
}

View file

@ -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;
}
});
}
}

View 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";
}