diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx
index abfb430cb..9020d2fbe 100644
--- a/ModelContextProtocol.slnx
+++ b/ModelContextProtocol.slnx
@@ -69,6 +69,7 @@
+
diff --git a/samples/TasksExtension/Program.cs b/samples/TasksExtension/Program.cs
index fff353307..50ddf0d03 100644
--- a/samples/TasksExtension/Program.cs
+++ b/samples/TasksExtension/Program.cs
@@ -1,18 +1,17 @@
// Demonstrates the MCP tasks extension (SEP-2663):
//
-// - A server is configured with InMemoryMcpTaskStore so that any [McpServerTool] invocation
+// - A server is configured with .WithTasks(store) so that any [McpServerTool] invocation
// becomes a background task when the client opts in via the per-request _meta marker.
-// - The client invokes the same tool two ways:
-// 1. CallToolAsync — the SDK auto-polls until the task completes and returns the final
-// CallToolResult, just like a synchronous call.
-// 2. CallToolRawAsync — the caller drives the lifecycle manually (GetTaskAsync polls,
-// CancelTaskAsync, etc.). Use this when you need to surface progress to a UI or stream
-// status updates rather than block on a single await.
+// - The client invokes the tool and manually drives the lifecycle via GetTaskAsync.
//
// Both server and client are wired together in-process over an in-memory pipe so the sample
// is self-contained — no separate server process or HTTP transport required.
+#pragma warning disable MCPEXP001, MCPEXP002, MCPEXP004
+
+using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
+using ModelContextProtocol.Extensions.Tasks;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.ComponentModel;
@@ -21,28 +20,25 @@
Pipe clientToServerPipe = new(), serverToClientPipe = new();
-await using McpServer server = McpServer.Create(
- new StreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()),
- new McpServerOptions
- {
- // Setting TaskStore is all that's needed for [McpServerTool]-attributed tools to be
- // automatically wrapped as background tasks when the client opts in.
- TaskStore = new InMemoryMcpTaskStore { DefaultPollIntervalMs = 250 },
- ToolCollection = [McpServerTool.Create(SlowTools.RunReport, new() { Name = "run-report" })],
- });
+var store = new InMemoryMcpTaskStore { DefaultPollIntervalMs = 250 };
+
+var services = new ServiceCollection();
+services.AddMcpServer()
+ .WithTools([McpServerTool.Create(SlowTools.RunReport, new() { Name = "run-report" })])
+ .WithTasks(store);
+services.AddSingleton(new StreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()));
+
+await using var serviceProvider = services.BuildServiceProvider();
+var server = serviceProvider.GetRequiredService();
_ = server.RunAsync();
await using McpClient client = await McpClient.CreateAsync(
- new StreamClientTransport(clientToServerPipe.Writer.AsStream(), serverToClientPipe.Reader.AsStream()));
-
-Console.WriteLine("=== CallToolAsync (auto-poll) ===");
-var auto = await client.CallToolAsync(
- new CallToolRequestParams { Name = "run-report" });
-Console.WriteLine($" result: {((TextContentBlock)auto.Content[0]).Text}");
-Console.WriteLine();
+ new StreamClientTransport(
+ serverInput: clientToServerPipe.Writer.AsStream(),
+ serverOutput: serverToClientPipe.Reader.AsStream()));
-Console.WriteLine("=== CallToolRawAsync (manual poll) ===");
-var raw = await client.CallToolRawAsync(new CallToolRequestParams { Name = "run-report" });
+Console.WriteLine("=== CallToolAsTaskAsync (manual poll) ===");
+var raw = await client.CallToolAsTaskAsync(new CallToolRequestParams { Name = "run-report" });
if (!raw.IsTask)
{
// Either the server doesn't advertise the tasks extension or it chose to run the call
@@ -88,9 +84,6 @@
continue;
case InputRequiredTaskResult inputRequired:
- // The auto-poll path (CallToolAsync above) routes these through the registered
- // ElicitationHandler/SamplingHandler automatically. The manual path needs to call
- // UpdateTaskAsync with responses for each outstanding key.
Console.WriteLine($" poll {pollCount}: input requested ({inputRequired.InputRequests?.Count ?? 0} key(s))");
continue;
}
diff --git a/samples/TasksExtension/TasksExtension.csproj b/samples/TasksExtension/TasksExtension.csproj
index 2f66badd7..fe01a8440 100644
--- a/samples/TasksExtension/TasksExtension.csproj
+++ b/samples/TasksExtension/TasksExtension.csproj
@@ -10,6 +10,8 @@
+
+
diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
index bfe81d19b..e78830802 100644
--- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
+++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
@@ -899,25 +899,17 @@ public Task UnsubscribeFromResourceAsync(
McpJsonUtilities.JsonContext.Default.EmptyResult,
cancellationToken: cancellationToken).AsTask();
}
-
///
/// Invokes a tool on the server.
///
- /// The name of the tool to call on the server.
+ /// The name of the tool to invoke.
/// An optional dictionary of arguments to pass to the tool.
- /// An optional progress reporter for server notifications.
+ /// An optional progress handler for tracking operation progress.
/// Optional request options including metadata, serialization settings, and progress tracking.
/// The to monitor for cancellation requests. The default is .
- /// The from the tool execution.
+ /// The result of the tool invocation.
/// is .
/// The request failed or the server returned an error response.
- ///
- /// This overload supports the tasks extension transparently. If the server responds with a
- /// task handle rather than an immediate result, this method polls tasks/get until the
- /// task completes, dispatching any entries through
- /// the client's registered sampling and elicitation handlers along the way. Use
- /// to disable automatic polling.
- ///
public ValueTask CallToolAsync(
string toolName,
IReadOnlyDictionary? arguments = null,
@@ -988,221 +980,19 @@ async ValueTask SendRequestWithProgressAsync(
/// The result of the request.
/// is .
/// The request failed or the server returned an error response.
- ///
- /// This method automatically includes the io.modelcontextprotocol/tasks extension capability
- /// in the request metadata. If the server returns a task handle instead of an immediate result,
- /// this method transparently polls tasks/get until the task completes, fails, or is cancelled.
- /// Use
- /// to receive the raw without automatic polling.
- ///
- public async ValueTask CallToolAsync(
- CallToolRequestParams requestParams,
- CancellationToken cancellationToken = default)
- {
- Throw.IfNull(requestParams);
-
- var augmented = await CallToolRawAsync(requestParams, cancellationToken).ConfigureAwait(false);
-
- if (!augmented.IsTask)
- {
- return augmented.Result!;
- }
-
- return await PollTaskToCompletionAsync(augmented.TaskCreated!, cancellationToken).ConfigureAwait(false);
- }
-
- ///
- /// Polls a task until it reaches a terminal state and returns the final .
- ///
- private async ValueTask PollTaskToCompletionAsync(
- CreateTaskResult taskCreated,
- CancellationToken cancellationToken)
- {
- // If the server claims InputRequired but never publishes new input requests after we have
- // already responded to everything it asked for, treat that as a stuck task. The client
- // can still cancel earlier via cancellationToken; this guard prevents an unbounded poll
- // loop when the server is misbehaving. The threshold is configurable via
- // McpClientOptions.MaxConsecutiveStuckPolls.
- int maxConsecutiveStuckPolls = MaxConsecutiveStuckPolls;
-
- string taskId = taskCreated.TaskId;
- long pollIntervalMs = taskCreated.PollIntervalMs ?? 1000;
- HashSet? resolvedRequestKeys = null;
- bool isFirstPoll = true;
- int consecutiveStuckPolls = 0;
-
- while (true)
- {
- // Skip the delay before the first poll: many tasks complete almost immediately and we
- // don't want to pay the poll interval as gratuitous latency.
- if (!isFirstPoll)
- {
- await Task.Delay(TimeSpan.FromMilliseconds(pollIntervalMs), cancellationToken).ConfigureAwait(false);
- }
- isFirstPoll = false;
-
- var taskResult = await GetTaskAsync(taskId, cancellationToken).ConfigureAwait(false);
-
- // Update poll interval if the server changed it.
- if (taskResult.PollIntervalMs is { } newInterval)
- {
- pollIntervalMs = newInterval;
- }
-
- switch (taskResult)
- {
- case CompletedTaskResult completed:
- return JsonSerializer.Deserialize(completed.Result, McpJsonUtilities.JsonContext.Default.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:
- // Dedup: only resolve input requests we haven't already responded to.
- var newRequests = new Dictionary();
- 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 inputResponses;
- try
- {
- inputResponses = await ResolveInputRequestsAsync(newRequests, cancellationToken).ConfigureAwait(false);
- }
- catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
- {
- throw;
- }
- catch
- {
- // The input handler failed (e.g., ElicitationHandler threw or no handler was registered).
- // Best-effort cancel of the server-side task so it doesn't stay stuck in InputRequired
- // until TTL expires.
- try
- {
- await CancelTaskAsync(taskId, CancellationToken.None).ConfigureAwait(false);
- }
- catch
- {
- // Swallow secondary failures; we're already propagating the original exception.
- }
-
- throw;
- }
-
- await UpdateTaskAsync(new UpdateTaskRequestParams
- {
- TaskId = taskId,
- InputResponses = inputResponses,
- }, cancellationToken).ConfigureAwait(false);
-
- resolvedRequestKeys ??= new HashSet(StringComparer.Ordinal);
- foreach (var key in inputResponses.Keys)
- {
- resolvedRequestKeys.Add(key);
- }
- }
- else if (++consecutiveStuckPolls >= maxConsecutiveStuckPolls)
- {
- // Best-effort cancel of the server-side task so it doesn't leak until TTL expires.
- try
- {
- await CancelTaskAsync(taskId, CancellationToken.None).ConfigureAwait(false);
- }
- catch
- {
- // Swallow secondary failures; we're already propagating an exception.
- }
-
- 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:
- // Continue polling.
- consecutiveStuckPolls = 0;
- break;
-
- default:
- throw new McpException(
- $"Unexpected task result type '{taskResult.GetType().Name}' for task '{taskId}'.");
- }
- }
- }
-
- ///
- /// Invokes a tool on the server with task extension support, returning the raw response
- /// without automatic polling. The caller is responsible for handling task lifecycle.
- ///
- /// The request parameters to send. The tasks extension capability will be injected into the request metadata.
- /// The to monitor for cancellation requests. The default is .
- /// A that is either an immediate result or a task handle.
- /// is .
- /// The request failed or the server returned an error response.
- ///
- ///
- /// Unlike , this method does not
- /// automatically poll for task completion. If the server returns a ,
- /// the caller must manage polling via .
- ///
- ///
- public async ValueTask> CallToolRawAsync(
+ public ValueTask CallToolAsync(
CallToolRequestParams requestParams,
CancellationToken cancellationToken = default)
{
Throw.IfNull(requestParams);
- var paramsWithMeta = new CallToolRequestParams
- {
- Name = requestParams.Name,
- Arguments = requestParams.Arguments,
- // The SEP-2663 Tasks extension requires the 2026-07-28 or later protocol revision. On an older session, send a plain tools/call
- // (no task capability envelope) so the server returns a direct CallToolResult and never
- // creates a task.
- Meta = IsJuly2026OrLaterProtocol() ? GetMetaWithTaskCapability(requestParams.Meta) : requestParams.Meta,
- };
-
- JsonRpcRequest jsonRpcRequest = new()
- {
- Method = RequestMethods.ToolsCall,
- Params = JsonSerializer.SerializeToNode(paramsWithMeta, McpJsonUtilities.JsonContext.Default.CallToolRequestParams),
- };
-
- JsonRpcResponse response = await SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false);
-
- // Discriminate based on resultType field.
- if (response.Result is JsonObject resultObj &&
- resultObj.TryGetPropertyValue("resultType", out var resultTypeNode) &&
- resultTypeNode?.GetValue() == "task")
- {
- var taskCreated = resultObj.Deserialize(McpJsonUtilities.JsonContext.Default.CreateTaskResult)
- ?? throw new JsonException("Failed to deserialize CreateTaskResult from response.");
- return new ResultOrCreatedTask(taskCreated);
- }
-
- var callToolResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.JsonContext.Default.CallToolResult)
- ?? throw new JsonException("Failed to deserialize CallToolResult from response.");
- return new ResultOrCreatedTask(callToolResult);
+ return SendRequestAsync(
+ RequestMethods.ToolsCall,
+ requestParams,
+ McpJsonUtilities.JsonContext.Default.CallToolRequestParams,
+ McpJsonUtilities.JsonContext.Default.CallToolResult,
+ cancellationToken: cancellationToken);
}
-
///
/// Sets the logging level for the server to control which log messages are sent to the client.
///
@@ -1257,111 +1047,6 @@ public Task SetLoggingLevelAsync(
McpJsonUtilities.JsonContext.Default.EmptyResult,
cancellationToken: cancellationToken).AsTask();
}
-
- ///
- /// Retrieves the current state of a task from the server.
- ///
- /// The stable identifier of the task to retrieve.
- /// The to monitor for cancellation requests. The default is .
- /// A subtype representing the current task state.
- /// is .
- /// The request failed or the server returned an error response.
- public ValueTask GetTaskAsync(
- string taskId,
- CancellationToken cancellationToken = default)
- {
- Throw.IfNull(taskId);
-
- return GetTaskAsync(new GetTaskRequestParams { TaskId = taskId }, cancellationToken);
- }
-
- ///
- /// Retrieves the current state of a task from the server.
- ///
- /// The request parameters to send in the request.
- /// The to monitor for cancellation requests. The default is .
- /// A subtype representing the current task state.
- /// is .
- /// The request failed or the server returned an error response.
- public ValueTask GetTaskAsync(
- GetTaskRequestParams requestParams,
- CancellationToken cancellationToken = default)
- {
- Throw.IfNull(requestParams);
- ThrowIfTasksNotSupported(nameof(GetTaskAsync));
-
- return SendRequestAsync(
- RequestMethods.TasksGet,
- requestParams,
- McpJsonUtilities.JsonContext.Default.GetTaskRequestParams,
- McpJsonUtilities.JsonContext.Default.GetTaskResult,
- cancellationToken: cancellationToken);
- }
-
- ///
- /// Provides input responses to a task that is in the state.
- ///
- /// The request parameters containing the task ID and input responses.
- /// The to monitor for cancellation requests. The default is .
- /// The result acknowledging the update.
- /// is .
- /// The request failed or the server returned an error response.
- public ValueTask UpdateTaskAsync(
- UpdateTaskRequestParams requestParams,
- CancellationToken cancellationToken = default)
- {
- Throw.IfNull(requestParams);
- ThrowIfTasksNotSupported(nameof(UpdateTaskAsync));
-
- return SendRequestAsync(
- RequestMethods.TasksUpdate,
- requestParams,
- McpJsonUtilities.JsonContext.Default.UpdateTaskRequestParams,
- McpJsonUtilities.JsonContext.Default.UpdateTaskResult,
- cancellationToken: cancellationToken);
- }
-
- ///
- /// Requests cancellation of an in-progress task on the server.
- ///
- /// The stable identifier of the task to cancel.
- /// The to monitor for cancellation requests. The default is .
- /// The result acknowledging the cancellation request.
- /// is .
- /// The request failed or the server returned an error response.
- public ValueTask CancelTaskAsync(
- string taskId,
- CancellationToken cancellationToken = default)
- {
- Throw.IfNull(taskId);
-
- return CancelTaskAsync(new CancelTaskRequestParams { TaskId = taskId }, cancellationToken);
- }
-
- ///
- /// Requests cancellation of an in-progress task on the server.
- ///
- /// The request parameters to send in the request.
- /// The to monitor for cancellation requests. The default is .
- /// The result acknowledging the cancellation request.
- /// is .
- /// The request failed or the server returned an error response.
- public ValueTask CancelTaskAsync(
- CancelTaskRequestParams requestParams,
- CancellationToken cancellationToken = default)
- {
- Throw.IfNull(requestParams);
- ThrowIfTasksNotSupported(nameof(CancelTaskAsync));
-
- return SendRequestAsync(
- RequestMethods.TasksCancel,
- requestParams,
- McpJsonUtilities.JsonContext.Default.CancelTaskRequestParams,
- McpJsonUtilities.JsonContext.Default.CancelTaskResult,
- cancellationToken: cancellationToken);
- }
-
- /// Converts a dictionary with values to a dictionary with values.
private static Dictionary? ToArgumentsDictionary(
IReadOnlyDictionary? arguments, JsonSerializerOptions options)
{
@@ -1380,43 +1065,4 @@ public ValueTask CancelTaskAsync(
return result;
}
- // Per SEP-2663 §51, the per-request opt-in uses the SEP-2575 capabilities envelope:
- // _meta/io.modelcontextprotocol/clientCapabilities/extensions/io.modelcontextprotocol/tasks = {}
- 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;
- }
-
- extensionsRoot.TryAdd(McpExtensions.Tasks, new JsonObject());
- return meta;
- }
-
- ///
- /// Throws when the negotiated protocol version does not support the SEP-2663 Tasks extension. Tasks
- /// require the 2026-07-28 or later protocol revision, and a task id only ever exists when the session
- /// negotiated such a revision, so invoking tasks/get, tasks/update, or tasks/cancel
- /// on an older session is a programming error rather than a recoverable protocol condition.
- ///
- private void ThrowIfTasksNotSupported(string operationName)
- {
- if (!IsJuly2026OrLaterProtocol())
- {
- throw new InvalidOperationException(
- $"'{operationName}' requires a newer protocol revision that supports tasks. " +
- $"The negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'.");
- }
- }
}
diff --git a/src/ModelContextProtocol.Core/Client/McpClient.cs b/src/ModelContextProtocol.Core/Client/McpClient.cs
index b238c59c3..be0f8ab87 100644
--- a/src/ModelContextProtocol.Core/Client/McpClient.cs
+++ b/src/ModelContextProtocol.Core/Client/McpClient.cs
@@ -73,7 +73,7 @@ protected McpClient()
public abstract Task Completion { get; }
///
- /// Resolves input requests embedded in an by dispatching
+ /// Resolves input requests by dispatching
/// each request to the appropriate registered handler.
///
///
@@ -82,16 +82,9 @@ protected McpClient()
///
/// The to monitor for cancellation requests.
/// A dictionary of responses keyed by the same identifiers as the input requests.
- private protected abstract ValueTask> ResolveInputRequestsAsync(
+ public abstract ValueTask> ResolveInputRequestsAsync(
IDictionary inputRequests, CancellationToken cancellationToken);
- ///
- /// Gets the maximum number of consecutive stuck-in- polls
- /// allowed by before the client cancels and throws.
- /// Sourced from .
- ///
- private protected abstract int MaxConsecutiveStuckPolls { get; }
-
///
/// Registers one or more tool definitions in the client's tool cache, enabling the transport
/// to send Mcp-Param-* headers for those tools without requiring a prior call.
diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs
index eb2fedc85..6d07da1c3 100644
--- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs
+++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs
@@ -178,10 +178,9 @@ private void RegisterHandlers(McpClientOptions options, NotificationHandlers not
public override Task Completion => _sessionHandler.CompletionTask;
///
- private protected override int MaxConsecutiveStuckPolls => _options.MaxConsecutiveStuckPolls;
///
- private protected override async ValueTask> ResolveInputRequestsAsync(
+ public override async ValueTask> ResolveInputRequestsAsync(
IDictionary inputRequests,
CancellationToken cancellationToken)
{
diff --git a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs
index aaf3cefa6..510934cd8 100644
--- a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs
+++ b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs
@@ -147,41 +147,4 @@ public McpClientHandlers Handlers
}
}
- ///
- /// Gets or sets the maximum number of consecutive task polls during which a task may report
- /// without publishing any new input requests, before
- /// the client treats the task as stuck, issues a best-effort tasks/cancel, and throws
- /// an .
- ///
- ///
- /// The maximum number of consecutive stuck polls allowed. The default value is 60.
- ///
- ///
- ///
- /// This guard prevents an unbounded poll loop when the server keeps a task in
- /// but never publishes new input requests after the
- /// client has already responded to every previously surfaced request. It only affects the
- /// long-poll path used by ;
- /// it does not affect direct calls.
- ///
- ///
- /// Callers should size this value with the configured server-side poll interval in mind: the
- /// effective wall-clock timeout is roughly MaxConsecutiveStuckPolls * pollIntervalMs.
- /// Setting this to a very small value can cause false positives for servers that are slow to
- /// surface follow-up input requests; setting it too large can mask misbehaving servers.
- ///
- ///
- /// The value is less than 1.
- public int MaxConsecutiveStuckPolls
- {
- get;
- set
- {
- if (value < 1)
- {
- throw new ArgumentOutOfRangeException(nameof(value), value, "must be greater than or equal to 1.");
- }
- field = value;
- }
- } = 60;
}
diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs
index da1b898dd..b193bb8de 100644
--- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs
+++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs
@@ -54,7 +54,13 @@ private static JsonSerializerOptions CreateDefaultOptions()
return options;
}
- internal static JsonTypeInfo GetTypeInfo(this JsonSerializerOptions options) =>
+ ///
+ /// Gets the resolved for from the specified options.
+ ///
+ /// The type whose serialization metadata should be resolved.
+ /// The serializer options providing the type-info resolver chain.
+ /// The resolved .
+ public static JsonTypeInfo GetTypeInfo(this JsonSerializerOptions options) =>
(JsonTypeInfo)options.GetTypeInfo(typeof(T));
internal static JsonElement DefaultMcpToolSchema { get; } = ParseJsonElement("""{"type":"object"}"""u8);
@@ -120,12 +126,6 @@ internal static bool IsValidToolOutputSchema(JsonElement element) =>
[JsonSerializable(typeof(ResourceUpdatedNotificationParams))]
[JsonSerializable(typeof(RootsListChangedNotificationParams))]
[JsonSerializable(typeof(ToolListChangedNotificationParams))]
- [JsonSerializable(typeof(TaskStatusNotificationParams))]
- [JsonSerializable(typeof(WorkingTaskNotificationParams))]
- [JsonSerializable(typeof(CompletedTaskNotificationParams))]
- [JsonSerializable(typeof(FailedTaskNotificationParams))]
- [JsonSerializable(typeof(CancelledTaskNotificationParams))]
- [JsonSerializable(typeof(InputRequiredTaskNotificationParams))]
// MCP Request Params / Results
[JsonSerializable(typeof(CallToolRequestParams))]
@@ -174,19 +174,6 @@ internal static bool IsValidToolOutputSchema(JsonElement element) =>
[JsonSerializable(typeof(IDictionary))]
[JsonSerializable(typeof(IDictionary))]
- [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(CreateTaskResult))]
-
// MCP Content
[JsonSerializable(typeof(ContentBlock))]
[JsonSerializable(typeof(TextContentBlock))]
diff --git a/src/ModelContextProtocol.Core/Protocol/McpExtensions.cs b/src/ModelContextProtocol.Core/Protocol/McpExtensions.cs
deleted file mode 100644
index a41e4a576..000000000
--- a/src/ModelContextProtocol.Core/Protocol/McpExtensions.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-namespace ModelContextProtocol.Protocol;
-
-///
-/// Provides constants for well-known MCP extension identifiers.
-///
-public static class McpExtensions
-{
- ///
- /// The extension identifier for the MCP Tasks extension.
- ///
- ///
- /// When included in client per-request capabilities, indicates the client can handle
- /// in lieu of a standard result.
- /// See the SEP-2663
- /// specification for details.
- ///
- public const string Tasks = "io.modelcontextprotocol/tasks";
-}
diff --git a/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs b/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs
index 4d9139953..29db6f5ae 100644
--- a/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs
+++ b/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs
@@ -53,24 +53,4 @@ public static class MetaKeys
/// belonging to different subscriptions on a shared channel (especially STDIO).
///
public const string SubscriptionId = "io.modelcontextprotocol/subscriptionId";
-
- ///
- /// The metadata key used to associate requests, responses, and notifications with a task.
- ///
- ///
- ///
- /// This constant defines the key "io.modelcontextprotocol/related-task" used in the
- /// _meta field to associate messages with their originating task across the entire
- /// request lifecycle.
- ///
- ///
- /// For example, an elicitation that a task-augmented tool call depends on must share the
- /// same related task ID with that tool call's task.
- ///
- ///
- /// For tasks/get, tasks/list, and tasks/cancel operations, this
- /// metadata should not be included as the taskId is already present in the message structure.
- ///
- ///
- public const string RelatedTask = "io.modelcontextprotocol/related-task";
}
diff --git a/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs b/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs
index 0bd3348f8..7911d9e33 100644
--- a/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs
+++ b/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs
@@ -144,16 +144,6 @@ public static class NotificationMethods
///
public const string CancelledNotification = "notifications/cancelled";
- ///
- /// The name of the notification sent by the server to push task status updates to subscribed clients.
- ///
- ///
- /// Part of the io.modelcontextprotocol/tasks extension.
- /// Each notification carries a complete task state for the current status, identical to what
- /// tasks/get would have returned at that moment.
- ///
- public const string TaskStatusNotification = "notifications/tasks/status";
-
///
/// The name of the notification sent first on a
/// response stream to indicate which notification types the server agreed to deliver.
diff --git a/src/ModelContextProtocol.Core/Protocol/NotificationParams.cs b/src/ModelContextProtocol.Core/Protocol/NotificationParams.cs
index 54432a4c2..b13a746cd 100644
--- a/src/ModelContextProtocol.Core/Protocol/NotificationParams.cs
+++ b/src/ModelContextProtocol.Core/Protocol/NotificationParams.cs
@@ -8,8 +8,8 @@ namespace ModelContextProtocol.Protocol;
///
public abstract class NotificationParams
{
- /// Prevent external derivations.
- private protected NotificationParams()
+ /// Initializes the base notification parameter type.
+ protected NotificationParams()
{
}
diff --git a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs
index a2884efba..29d7d3764 100644
--- a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs
+++ b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs
@@ -125,33 +125,6 @@ public static class RequestMethods
///
public const string Initialize = "initialize";
- ///
- /// The name of the request method sent from the client to poll for task completion.
- ///
- ///
- /// Part of the io.modelcontextprotocol/tasks extension.
- /// Clients poll for task status by sending this request with the task ID.
- ///
- public const string TasksGet = "tasks/get";
-
- ///
- /// The name of the request method sent from the client to provide input responses to a task.
- ///
- ///
- /// Part of the io.modelcontextprotocol/tasks extension.
- /// Used when a task has input_required status and the client needs to fulfill outstanding requests.
- ///
- public const string TasksUpdate = "tasks/update";
-
- ///
- /// The name of the request method sent from the client to signal intent to cancel a task.
- ///
- ///
- /// Part of the io.modelcontextprotocol/tasks extension.
- /// Cancellation is cooperative — the server decides whether and when to honor it.
- ///
- public const string TasksCancel = "tasks/cancel";
-
///
/// The name of the request method sent from the client to discover the server's protocol versions,
/// capabilities, and metadata.
diff --git a/src/ModelContextProtocol.Core/Protocol/RequestParams.cs b/src/ModelContextProtocol.Core/Protocol/RequestParams.cs
index 67a8f00ba..1f14aa754 100644
--- a/src/ModelContextProtocol.Core/Protocol/RequestParams.cs
+++ b/src/ModelContextProtocol.Core/Protocol/RequestParams.cs
@@ -11,8 +11,8 @@ namespace ModelContextProtocol.Protocol;
///
public abstract class RequestParams
{
- /// Prevent external derivations.
- private protected RequestParams()
+ /// Initializes the base request parameter type.
+ protected RequestParams()
{
}
diff --git a/src/ModelContextProtocol.Core/Protocol/Result.cs b/src/ModelContextProtocol.Core/Protocol/Result.cs
index 15eb6fa46..e64be9728 100644
--- a/src/ModelContextProtocol.Core/Protocol/Result.cs
+++ b/src/ModelContextProtocol.Core/Protocol/Result.cs
@@ -8,8 +8,8 @@ namespace ModelContextProtocol.Protocol;
///
public abstract class Result
{
- /// Prevent external derivations.
- private protected Result()
+ /// Initializes the base result type.
+ protected Result()
{
}
@@ -28,10 +28,8 @@ private protected Result()
///
///
/// When absent or set to "complete", the result is a normal completed response.
- /// When set to "input_required", the result is an indicating
- /// that additional input is needed before the request can be completed.
- /// When set to "task", the result is a indicating that the server
- /// has created a long-running task in lieu of returning the result directly.
+ /// Other values discriminate alternate result subtypes so callers can choose the appropriate
+ /// concrete payload to deserialize.
///
///
/// Defaults to , which is equivalent to "complete".
diff --git a/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs b/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs
new file mode 100644
index 000000000..e2e1f112e
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs
@@ -0,0 +1,78 @@
+using System.Text.Json.Serialization.Metadata;
+
+namespace ModelContextProtocol.Protocol;
+
+///
+/// Represents the result of a request that may return either the standard result or an alternate
+/// subtype for scenarios like asynchronous task execution.
+///
+/// The standard result type for the request (e.g., ).
+///
+///
+/// Extensions that augment request handling (such as the Tasks extension) use this type to indicate
+/// that the server returned an alternate result instead of the normal one. The alternate carries its
+/// own so the transport layer can serialize it without compile-time knowledge
+/// of the concrete type.
+///
+///
+/// Use to determine which variant was returned, then access either
+/// for the immediate result or for the alternate.
+///
+///
+public class ResultOrAlternate where TResult : Result
+{
+ private readonly TResult? _result;
+ private readonly Result? _alternate;
+ private readonly JsonTypeInfo? _alternateTypeInfo;
+
+ ///
+ /// Initializes a new instance of with an immediate result.
+ ///
+ /// The standard result returned by the server.
+ public ResultOrAlternate(TResult result)
+ {
+ Throw.IfNull(result);
+ _result = result;
+ }
+
+ ///
+ /// Initializes a new instance of with an alternate result.
+ ///
+ /// The alternate result.
+ /// The used to serialize the alternate result.
+ public ResultOrAlternate(Result alternate, JsonTypeInfo alternateTypeInfo)
+ {
+ Throw.IfNull(alternate);
+ Throw.IfNull(alternateTypeInfo);
+ _alternate = alternate;
+ _alternateTypeInfo = alternateTypeInfo;
+ }
+
+ ///
+ /// Gets a value indicating whether the server returned an alternate result instead of the standard result.
+ ///
+ public bool IsAlternate => _alternate is not null;
+
+ ///
+ /// Gets the immediate result, or if the server returned an alternate.
+ ///
+ public TResult? Result => _result;
+
+ ///
+ /// Gets the alternate result, or if the server returned the standard result.
+ ///
+ public Result? Alternate => _alternate;
+
+ ///
+ /// Gets the for serializing the alternate result, or
+ /// if the server returned the standard result.
+ ///
+ public JsonTypeInfo? AlternateTypeInfo => _alternateTypeInfo;
+
+ ///
+ /// Implicitly converts a to a
+ /// wrapping the immediate result.
+ ///
+ /// The result to wrap.
+ public static implicit operator ResultOrAlternate(TResult result) => new(result);
+}
diff --git a/src/ModelContextProtocol.Core/RequestHandlers.cs b/src/ModelContextProtocol.Core/RequestHandlers.cs
index f15ce316c..ff167cc7d 100644
--- a/src/ModelContextProtocol.Core/RequestHandlers.cs
+++ b/src/ModelContextProtocol.Core/RequestHandlers.cs
@@ -47,44 +47,29 @@ public void Set(
}
///
- /// Registers a handler that may return either a standard result or a
- /// for task-augmented execution.
+ /// Registers a handler that may return either a standard result or an alternate
+ /// subtype for scenarios like task-augmented execution.
///
- public void SetTaskAugmented(
+ public void SetWithAlternate(
string method,
- Func>> handler,
+ Func>> handler,
JsonTypeInfo requestTypeInfo,
- JsonTypeInfo responseTypeInfo,
- JsonTypeInfo taskResultTypeInfo)
+ JsonTypeInfo responseTypeInfo)
where TResult : Result
{
Throw.IfNull(method);
Throw.IfNull(handler);
Throw.IfNull(requestTypeInfo);
Throw.IfNull(responseTypeInfo);
- Throw.IfNull(taskResultTypeInfo);
this[method] = async (request, cancellationToken) =>
{
TParams typedRequest = JsonSerializer.Deserialize(request.Params, requestTypeInfo)!;
var augmented = await handler(typedRequest, request, cancellationToken).ConfigureAwait(false);
- if (augmented.IsTask)
+ if (augmented.IsAlternate)
{
- // Guard against a misconfiguration where a handler opts into task-augmented
- // execution but the server has no task lifecycle handlers wired up. Without
- // tasks/get, a client that received a CreateTaskResult would have no way to
- // poll the task to completion. Configure McpServerOptions.TaskStore or set
- // the task handlers explicitly via McpServerOptions.Handlers.
- if (!ContainsKey(RequestMethods.TasksGet))
- {
- throw new InvalidOperationException(
- $"Handler for '{method}' returned a {nameof(CreateTaskResult)}, but the server has no " +
- $"'{RequestMethods.TasksGet}' handler registered. Configure McpServerOptions.TaskStore " +
- "or set the task handlers explicitly in McpServerOptions.Handlers before starting the server.");
- }
-
- return JsonSerializer.SerializeToNode(augmented.TaskCreated!, taskResultTypeInfo);
+ return JsonSerializer.SerializeToNode(augmented.Alternate!, augmented.AlternateTypeInfo!);
}
return JsonSerializer.SerializeToNode(augmented.Result!, responseTypeInfo);
diff --git a/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs b/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs
index 9d9774e8b..c31125af7 100644
--- a/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs
+++ b/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs
@@ -42,7 +42,7 @@ public IList> ListTool
/// requests. The handler should implement logic to execute the requested tool and return appropriate results.
///
///
- /// Cannot be used together with . If both are non-empty at configuration time,
+ /// Cannot be used together with . If both are non-empty at configuration time,
/// an will be thrown.
///
///
@@ -57,21 +57,21 @@ public IList> CallToolFi
}
///
- /// Gets or sets the filters for the call-tool handler pipeline with task support.
+ /// Gets or sets the filters for the call-tool handler pipeline with alternate result support.
///
///
///
- /// These filters wrap the task-augmented call-tool handler whose return type is
- /// . Use these filters when the server's tool pipeline
- /// supports returning either an immediate or a
- /// for asynchronous execution.
+ /// These filters wrap the alternate-result call-tool handler whose return type is
+ /// . Use these filters when the server's tool pipeline
+ /// supports returning either an immediate or an alternate
+ /// subtype for asynchronous execution.
///
///
/// Cannot be used together with . If both are non-empty at configuration time,
/// an will be thrown.
///
///
- public IList>> CallToolWithTaskFilters
+ public IList>> CallToolWithAlternateFilters
{
get => field ??= [];
set
diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs
index 4f739c28a..d6cb83c70 100644
--- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs
+++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs
@@ -23,6 +23,52 @@ public abstract partial class McpServer : McpSession
private static Dictionary>? s_elicitAllowedProperties = null;
+ ///
+ /// Ambient interceptor that, when installed, redirects server-initiated requests
+ /// (,
+ /// , and
+ /// ) away from the
+ /// transport. The interceptor receives the request method and pre-serialized parameters and
+ /// returns the serialized result. Used by extensions (such as the Tasks extension) to surface
+ /// these requests through an alternate channel during background execution.
+ ///
+ private static readonly AsyncLocal>?> s_outgoingRequestInterceptor = new();
+
+ ///
+ /// Gets the currently installed outgoing-request interceptor for the ambient execution context, if any.
+ ///
+ internal static Func>? CurrentOutgoingRequestInterceptor => s_outgoingRequestInterceptor.Value;
+
+ ///
+ /// Installs an interceptor that redirects server-initiated requests for the duration of the
+ /// returned scope on the current asynchronous execution context.
+ ///
+ ///
+ /// The interceptor invoked for each outgoing request. It receives the request method, the
+ /// pre-serialized request parameters (or ), and a cancellation token, and
+ /// returns the serialized result (or to indicate no result).
+ ///
+ /// An that restores the previous interceptor when disposed.
+ /// is .
+ ///
+ /// While an interceptor is installed, the redirected methods skip their client-capability checks,
+ /// because the alternate channel is responsible for delivering the request to the client.
+ ///
+ [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)]
+ public IDisposable InterceptOutgoingRequests(Func> interceptor)
+ {
+ Throw.IfNull(interceptor);
+
+ var previous = s_outgoingRequestInterceptor.Value;
+ s_outgoingRequestInterceptor.Value = interceptor;
+ return new OutgoingRequestInterceptorScope(previous);
+ }
+
+ private sealed class OutgoingRequestInterceptorScope(Func>? previous) : IDisposable
+ {
+ public void Dispose() => s_outgoingRequestInterceptor.Value = previous;
+ }
+
///
/// Creates a new instance of an .
///
@@ -61,11 +107,6 @@ public static McpServer Create(
/// , which is always open for the duration of
/// the request, rather than relying on the optional standalone GET SSE stream.
///
- ///
- /// When called during task-augmented tool execution, this method automatically updates the task
- /// status to while waiting for the client response,
- /// then returns to when the response is received.
- ///
///
[Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)]
public ValueTask SampleAsync(
@@ -74,14 +115,13 @@ public ValueTask SampleAsync(
{
Throw.IfNull(requestParams);
- // If executing inside a background task, redirect sampling through the task store.
- // Capability checks (ThrowIfSamplingUnsupported) are intentionally skipped here because the
- // client opted into the tasks extension when submitting the originating request, and input
- // requests are delivered through the tasks/get response channel rather than as direct
- // server->client requests. See SendRequestViaTaskAsync remarks.
- if (McpTaskExecutionContext.Current.Value is { } taskContext)
+ // If an outgoing-request interceptor is installed (e.g., during background task execution),
+ // redirect sampling through it. Capability checks (ThrowIfSamplingUnsupported) are
+ // intentionally skipped because the interceptor's alternate channel is responsible for
+ // delivering the request to the client. See SendRequestViaInterceptorAsync remarks.
+ if (CurrentOutgoingRequestInterceptor is { } interceptor)
{
- return SendRequestViaTaskAsync(taskContext, RequestMethods.SamplingCreateMessage, requestParams,
+ return SendRequestViaInterceptorAsync(interceptor, RequestMethods.SamplingCreateMessage, requestParams,
McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams,
McpJsonUtilities.JsonContext.Default.CreateMessageResult,
cancellationToken);
@@ -282,14 +322,13 @@ public ValueTask RequestRootsAsync(
{
Throw.IfNull(requestParams);
- // If executing inside a background task, redirect through the task store.
- // Capability checks (ThrowIfRootsUnsupported) are intentionally skipped here because the
- // client opted into the tasks extension when submitting the originating request, and input
- // requests are delivered through the tasks/get response channel rather than as direct
- // server->client requests. See SendRequestViaTaskAsync remarks.
- if (McpTaskExecutionContext.Current.Value is { } taskContext)
+ // If an outgoing-request interceptor is installed (e.g., during background task execution),
+ // redirect through it. Capability checks (ThrowIfRootsUnsupported) are intentionally skipped
+ // because the interceptor's alternate channel is responsible for delivering the request to
+ // the client. See SendRequestViaInterceptorAsync remarks.
+ if (CurrentOutgoingRequestInterceptor is { } interceptor)
{
- return SendRequestViaTaskAsync(taskContext, RequestMethods.RootsList, requestParams,
+ return SendRequestViaInterceptorAsync(interceptor, RequestMethods.RootsList, requestParams,
McpJsonUtilities.JsonContext.Default.ListRootsRequestParams,
McpJsonUtilities.JsonContext.Default.ListRootsResult,
cancellationToken);
@@ -322,11 +361,6 @@ public ValueTask RequestRootsAsync(
/// , which is always open for the duration of
/// the request, rather than relying on the optional standalone GET SSE stream.
///
- ///
- /// When called during task-augmented tool execution, this method automatically updates the task
- /// status to while waiting for user input,
- /// then returns to when the response is received.
- ///
///
public async ValueTask ElicitAsync(
ElicitRequestParams requestParams,
@@ -334,18 +368,15 @@ public async ValueTask ElicitAsync(
{
Throw.IfNull(requestParams);
- // If executing inside a background task, redirect elicitation through the task store.
- // Capability checks (ThrowIfElicitationUnsupported) are intentionally skipped here because
- // the client opted into the tasks extension when submitting the originating request, and
- // input requests are delivered through the tasks/get response channel rather than as
- // direct server->client requests. See SendRequestViaTaskAsync remarks.
- if (McpTaskExecutionContext.Current.Value is { } taskContext)
- {
- var taskResult = await SendRequestViaTaskAsync(taskContext, RequestMethods.ElicitationCreate, requestParams,
- McpJsonUtilities.JsonContext.Default.ElicitRequestParams,
- McpJsonUtilities.JsonContext.Default.ElicitResult,
- cancellationToken).ConfigureAwait(false);
- return taskResult ?? new ElicitResult { Action = "cancel" };
+ // If an outgoing-request interceptor is installed (e.g., during background task execution),
+ // redirect elicitation through it. Capability checks (ThrowIfElicitationUnsupported) are
+ // intentionally skipped because the interceptor's alternate channel is responsible for
+ // delivering the request to the client. See SendRequestViaInterceptorAsync remarks.
+ if (CurrentOutgoingRequestInterceptor is { } interceptor)
+ {
+ var paramsNode = JsonSerializer.SerializeToNode(requestParams, McpJsonUtilities.JsonContext.Default.ElicitRequestParams);
+ var resultNode = await interceptor(RequestMethods.ElicitationCreate, paramsNode, cancellationToken).ConfigureAwait(false);
+ return resultNode?.Deserialize(McpJsonUtilities.JsonContext.Default.ElicitResult) ?? new ElicitResult { Action = "cancel" };
}
ThrowIfElicitationUnsupported(requestParams);
@@ -422,26 +453,6 @@ public async ValueTask> ElicitAsync(
return new ElicitResult { Action = raw.Action, Content = typed };
}
- ///
- /// Sends a task status notification to the connected client.
- ///
- /// The task status notification parameters to send.
- /// The to monitor for cancellation requests. The default is .
- /// A task that represents the asynchronous send operation.
- /// is .
- public Task SendTaskStatusNotificationAsync(
- TaskStatusNotificationParams notificationParams,
- CancellationToken cancellationToken = default)
- {
- Throw.IfNull(notificationParams);
-
- return SendNotificationAsync(
- NotificationMethods.TaskStatusNotification,
- notificationParams,
- McpJsonUtilities.JsonContext.Default.TaskStatusNotificationParams,
- cancellationToken);
- }
-
///
/// Builds a request schema for elicitation based on the public serializable properties of .
///
@@ -592,84 +603,26 @@ private void ThrowIfRootsUnsupported()
}
///
- /// Creates a scope that redirects server-initiated requests (elicitation, sampling, list roots) through
- /// the task store as input requests for the duration of the scope. Use this when executing tool logic
- /// in the background as a task, so that any server-to-client requests are surfaced to the client via
- /// the task's state instead of direct JSON-RPC messages.
- ///
- /// The task ID in the store.
- /// The task store to write input requests to.
- /// An that restores the previous context when disposed.
- public IDisposable CreateMcpTaskScope(
- string taskId,
- IMcpTaskStore store)
- {
- Throw.IfNull(taskId);
- Throw.IfNull(store);
-
- var previous = McpTaskExecutionContext.Current.Value;
- McpTaskExecutionContext.Current.Value = new McpTaskExecutionContext
- {
- TaskId = taskId,
- Store = store,
- };
- return new McpTaskExecutionContext.Scope(previous);
- }
-
- ///
- /// Sends a server-initiated request through the task store as an input request, then awaits the response.
+ /// Sends a server-initiated request through the installed outgoing-request interceptor, then awaits the response.
///
///
- /// When executing inside a task scope, capability negotiation checks (such as
+ /// When an interceptor is installed, capability negotiation checks (such as
/// , , and
/// ) are intentionally skipped by the callers
- /// of this helper. The task channel itself is the negotiated capability: the client opted
- /// in to the tasks extension when it submitted the originating request, and is responsible
- /// for handling or rejecting the input requests surfaced through tasks/get.
+ /// of this helper. The interceptor's alternate channel is the negotiated capability and is
+ /// responsible for delivering the request to the client or rejecting it.
///
- private async ValueTask SendRequestViaTaskAsync(
- McpTaskExecutionContext taskContext,
+ private async ValueTask SendRequestViaInterceptorAsync(
+ Func> interceptor,
string method,
TRequest request,
JsonTypeInfo requestTypeInfo,
JsonTypeInfo responseTypeInfo,
CancellationToken cancellationToken)
{
- var requestId = Guid.NewGuid().ToString("N");
- var paramsJson = JsonSerializer.SerializeToElement(request, requestTypeInfo);
-
- var inputRequest = new InputRequest
- {
- Method = method,
- Params = paramsJson,
- };
-
- var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
-
- void handler(InputResponseReceivedEventArgs args)
- {
- if (args.TaskId == taskContext.TaskId && args.RequestId == requestId)
- {
- tcs.TrySetResult(args.Response);
- }
- }
-
- taskContext.Store.InputResponseReceived += handler;
- try
- {
- await taskContext.Store.SetInputRequestsAsync(
- taskContext.TaskId,
- new Dictionary { [requestId] = inputRequest },
- cancellationToken).ConfigureAwait(false);
-
- var response = await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
-
- return response.Deserialize(responseTypeInfo)!;
- }
- finally
- {
- taskContext.Store.InputResponseReceived -= handler;
- }
+ var paramsNode = JsonSerializer.SerializeToNode(request, requestTypeInfo);
+ var resultNode = await interceptor(method, paramsNode, cancellationToken).ConfigureAwait(false);
+ return resultNode is null ? default! : resultNode.Deserialize(responseTypeInfo)!;
}
private void ThrowIfElicitationUnsupported(ElicitRequestParams request)
diff --git a/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs b/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs
index 4f9509b9d..37d2a3d73 100644
--- a/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs
+++ b/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs
@@ -42,19 +42,19 @@ public sealed class McpServerHandlers
///
/// This handler is invoked when a client makes a call to a tool that isn't found in the collection.
/// The handler should implement logic to execute the requested tool and return appropriate results.
- /// Use instead if the tool may return a
- /// for asynchronous execution.
+ /// Use instead if the tool may return an alternate result
+ /// for the caller to handle.
///
- /// is already set.
+ /// is already set.
public McpRequestHandler? CallToolHandler
{
get;
set
{
- if (value is not null && CallToolWithTaskHandler is not null)
+ if (value is not null && CallToolWithAlternateHandler is not null)
{
throw new InvalidOperationException(
- $"Cannot set {nameof(CallToolHandler)} when {nameof(CallToolWithTaskHandler)} is already set. Only one call tool handler may be configured.");
+ $"Cannot set {nameof(CallToolHandler)} when {nameof(CallToolWithAlternateHandler)} is already set. Only one call tool handler may be configured.");
}
field = value;
@@ -62,20 +62,19 @@ public McpRequestHandler? CallToolHandler
}
///
- /// Gets or sets the handler for requests with task support.
+ /// Gets or sets the handler for requests with alternate result support.
///
///
///
/// This handler is invoked when a client makes a call to a tool, allowing the tool to return either
- /// a for immediate results or a for
- /// long-running asynchronous operations.
+ /// a for immediate results or an alternate subtype.
///
///
/// Cannot be set if is already set.
///
///
/// is already set.
- public McpRequestHandler>? CallToolWithTaskHandler
+ public McpRequestHandler>? CallToolWithAlternateHandler
{
get;
set
@@ -83,7 +82,7 @@ public McpRequestHandler? SetLoggingLevelHandler { get; set; }
- ///
- /// Gets or sets the handler for requests.
- ///
- ///
- ///
- /// This handler is invoked when a client polls for the current state of a task.
- /// The handler should return the appropriate subtype
- /// based on the task's current status (for example, ,
- /// , ,
- /// , or ).
- ///
- ///
- /// Setting is the recommended way to wire all three
- /// task lifecycle handlers (, ,
- /// and ) from a single source while still allowing explicit
- /// handlers to override any slot. If can return a
- /// but no is configured (either
- /// directly or via a task store), the server throws
- /// when processing the request so misconfigured deployments fail loudly instead of producing
- /// unpollable tasks.
- ///
- ///
- public McpRequestHandler? GetTaskHandler { get; set; }
-
- ///
- /// Gets or sets the handler for requests.
- ///
- ///
- ///
- /// This handler is invoked when a client provides input responses for a task
- /// that is in the state. Responses keyed
- /// by an identifier that does not currently correspond to an outstanding input request
- /// (including responses for tasks in a terminal state) should be silently ignored per
- /// SEP-2663.
- ///
- ///
- /// Prefer configuring instead of setting this
- /// handler directly; the default implementation built from the store dispatches to
- /// and raises
- /// to wake any pending
- ///
- /// or
- /// calls executing inside a task scope.
- ///
- ///
- public McpRequestHandler? UpdateTaskHandler { get; set; }
-
- ///
- /// Gets or sets the handler for requests.
- ///
- ///
- ///
- /// This handler is invoked when a client requests cancellation of an in-progress task.
- /// Per SEP-2663, cancellation is cooperative and eventually consistent: the handler should
- /// always acknowledge the request with , even if the task is
- /// unknown, already terminal, or cannot actually be stopped. Whether the task transitions
- /// to is up to the implementation.
- ///
- ///
- /// Prefer configuring instead of setting this
- /// handler directly; the default implementation built from the store calls
- /// and signals the per-task
- /// so the tool's
- /// observes cancellation.
- ///
- ///
- public McpRequestHandler? CancelTaskHandler { get; set; }
-
/// Gets or sets notification handlers to register with the server.
///
///
diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs
index f6eb0d60e..f4e1c52cc 100644
--- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs
+++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs
@@ -28,7 +28,6 @@ internal sealed partial class McpServerImpl : McpServer
private readonly RequestHandlers _requestHandlers;
private readonly McpSessionHandler _sessionHandler;
private readonly SemaphoreSlim _disposeLock = new(1, 1);
- private readonly ConcurrentDictionary _taskCancellationSources = new();
private readonly ConcurrentDictionary _mrtrContinuations = new();
private readonly ConcurrentDictionary _mrtrContextsByRequestId = new();
@@ -95,8 +94,8 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact
ConfigureCompletion(options);
ConfigureSubscriptions(options);
ConfigureExperimentalAndExtensions(options);
- ConfigureTasks(options);
ConfigureMrtr();
+ ConfigureCustomRequestHandlers(options);
// Register any notification handlers that were provided.
if (options.Handlers.NotificationHandlers is { } notificationHandlers)
@@ -312,13 +311,6 @@ public override async ValueTask DisposeAsync()
_disposed = true;
- foreach (var kvp in _taskCancellationSources)
- {
- kvp.Value.Cancel();
- kvp.Value.Dispose();
- }
- _taskCancellationSources.Clear();
-
// Dispose the session handler - cancels message processing and waits for all
// in-flight request handlers (including retries in AwaitMrtrHandlerAsync) to complete.
// After this returns, no new requests can be processed and no new MRTR continuations
@@ -753,116 +745,30 @@ private void ConfigureCompletion(McpServerOptions options)
return result;
}
-
- private void ConfigureTasks(McpServerOptions options)
+ private void ConfigureExperimentalAndExtensions(McpServerOptions options)
{
- var getTaskHandler = options.Handlers.GetTaskHandler;
- var updateTaskHandler = options.Handlers.UpdateTaskHandler;
- var cancelTaskHandler = options.Handlers.CancelTaskHandler;
- var taskStore = options.TaskStore;
+ ServerCapabilities.Experimental = options.Capabilities?.Experimental;
+ ServerCapabilities.Extensions = options.Capabilities?.Extensions;
+ }
- // If a task store is provided, wire up handlers from it for any that aren't explicitly set.
- if (taskStore is not null)
+ private void ConfigureCustomRequestHandlers(McpServerOptions options)
+ {
+#pragma warning disable MCPEXP002
+ if (options.RequestHandlers is not { Count: > 0 } customHandlers)
+#pragma warning restore MCPEXP002
{
- getTaskHandler ??= async (request, cancellationToken) =>
- {
- var info = await taskStore.GetTaskAsync(request.Params!.TaskId, cancellationToken).ConfigureAwait(false);
- return info is null
- ? throw new McpProtocolException($"Unknown task: '{request.Params.TaskId}'", McpErrorCode.InvalidParams)
- : ToGetTaskResult(info);
- };
-
- updateTaskHandler ??= async (request, cancellationToken) =>
- {
- var inputResponses = request.Params!.InputResponses ?? new Dictionary();
- await taskStore.ResolveInputRequestsAsync(request.Params.TaskId, inputResponses, cancellationToken).ConfigureAwait(false);
-
- return new UpdateTaskResult();
- };
-
- cancelTaskHandler ??= async (request, cancellationToken) =>
- {
- // Idempotent ack per SEP-2663: always return CancelTaskResult regardless of whether
- // the task was known/cancellable. The store's SetCancelledAsync no-ops for unknown
- // or already-terminal tasks; we still surface a success response to the client.
- await taskStore.SetCancelledAsync(request.Params!.TaskId, cancellationToken).ConfigureAwait(false);
-
- // Signal the task's CancellationTokenSource if one exists. Whichever side
- // (this handler or the background runner's finally block) wins TryRemove owns disposal,
- // which prevents the runner from observing ObjectDisposedException through cts.Token.
- if (_taskCancellationSources.TryRemove(request.Params.TaskId, out var cts))
- {
- cts.Cancel();
- cts.Dispose();
- }
-
- return new CancelTaskResult();
- };
+ return;
}
- if (getTaskHandler is null && updateTaskHandler is null && cancelTaskHandler is null)
+ foreach (var entry in customHandlers)
{
- return;
+ SetRawHandler(entry.Method, entry.Handler);
}
-
- getTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams));
- updateTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams));
- cancelTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams));
-
- // The tasks/* methods do not exist before the 2026-07-28 revision (SEP-2663). Reject them with
- // MethodNotFound when the request was negotiated under a legacy protocol version. The handlers
- // stay registered so a dual-era server still serves them for 2026-07-28 requests.
- getTaskHandler = GateTaskMethodToJuly2026OrLaterProtocol(getTaskHandler, RequestMethods.TasksGet);
- updateTaskHandler = GateTaskMethodToJuly2026OrLaterProtocol(updateTaskHandler, RequestMethods.TasksUpdate);
- cancelTaskHandler = GateTaskMethodToJuly2026OrLaterProtocol(cancelTaskHandler, RequestMethods.TasksCancel);
-
- // Advertise tasks extension in server capabilities.
- ServerCapabilities.Extensions ??= new Dictionary();
- ServerCapabilities.Extensions[McpExtensions.Tasks] = new JsonObject();
-
- SetHandler(
- RequestMethods.TasksGet,
- getTaskHandler,
- McpJsonUtilities.JsonContext.Default.GetTaskRequestParams,
- McpJsonUtilities.JsonContext.Default.GetTaskResult);
-
- SetHandler(
- RequestMethods.TasksUpdate,
- updateTaskHandler,
- McpJsonUtilities.JsonContext.Default.UpdateTaskRequestParams,
- McpJsonUtilities.JsonContext.Default.UpdateTaskResult);
-
- SetHandler(
- RequestMethods.TasksCancel,
- cancelTaskHandler,
- McpJsonUtilities.JsonContext.Default.CancelTaskRequestParams,
- McpJsonUtilities.JsonContext.Default.CancelTaskResult);
}
- ///
- /// Wraps a tasks/* request handler so it throws unless the
- /// request was negotiated under the 2026-07-28 or later revision. The tasks extension (SEP-2663) only
- /// interoperates on the 2026-07-28 revision, and these methods don't exist on older peers.
- ///
- private McpRequestHandler GateTaskMethodToJuly2026OrLaterProtocol(
- McpRequestHandler inner, string method)
- => (request, cancellationToken) =>
- {
- if (!IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest))
- {
- throw new McpProtocolException(
- $"The method '{method}' requires a newer protocol revision that supports tasks; " +
- $"the negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'.",
- McpErrorCode.MethodNotFound);
- }
-
- return inner(request, cancellationToken);
- };
-
- private void ConfigureExperimentalAndExtensions(McpServerOptions options)
+ private void SetRawHandler(string method, Func> handler)
{
- ServerCapabilities.Experimental = options.Capabilities?.Experimental;
- ServerCapabilities.Extensions = options.Capabilities?.Extensions;
+ _requestHandlers[method] = (request, ct) => handler(request, ct).AsTask();
}
private void ConfigureResources(McpServerOptions options)
@@ -1134,11 +1040,11 @@ private void ConfigureTools(McpServerOptions options)
{
var listToolsHandler = options.Handlers.ListToolsHandler;
var callToolHandler = options.Handlers.CallToolHandler;
- var callToolWithTaskHandler = options.Handlers.CallToolWithTaskHandler;
+ var callToolWithAlternateHandler = options.Handlers.CallToolWithAlternateHandler;
var tools = options.ToolCollection;
var toolsCapability = options.Capabilities?.Tools;
- if (listToolsHandler is null && callToolHandler is null && callToolWithTaskHandler is null && tools is null &&
+ if (listToolsHandler is null && callToolHandler is null && callToolWithAlternateHandler is null && tools is null &&
toolsCapability is null)
{
return;
@@ -1150,17 +1056,17 @@ private void ConfigureTools(McpServerOptions options)
var listChanged = toolsCapability?.ListChanged;
var callToolFilters = options.Filters.Request.CallToolFilters;
- var callToolWithTaskFilters = options.Filters.Request.CallToolWithTaskFilters;
+ var callToolWithAlternateFilters = options.Filters.Request.CallToolWithAlternateFilters;
- // Validate: cannot mix non-task filters/handler with task filters/handler.
- bool hasNonTaskPath = callToolHandler is not null || callToolFilters.Count > 0;
- bool hasTaskPath = callToolWithTaskHandler is not null || callToolWithTaskFilters.Count > 0;
+ // Validate: cannot mix non-alternate filters/handler with alternate filters/handler.
+ bool hasNonAlternatePath = callToolHandler is not null || callToolFilters.Count > 0;
+ bool hasAlternatePath = callToolWithAlternateHandler is not null || callToolWithAlternateFilters.Count > 0;
- if (hasNonTaskPath && hasTaskPath)
+ if (hasNonAlternatePath && hasAlternatePath)
{
throw new InvalidOperationException(
- $"Cannot mix non-task ({nameof(McpServerHandlers.CallToolHandler)}/{nameof(McpRequestFilters.CallToolFilters)}) " +
- $"with task-based ({nameof(McpServerHandlers.CallToolWithTaskHandler)}/{nameof(McpRequestFilters.CallToolWithTaskFilters)}). Use one style or the other.");
+ $"Cannot mix non-alternate ({nameof(McpServerHandlers.CallToolHandler)}/{nameof(McpRequestFilters.CallToolFilters)}) " +
+ $"with alternate-based ({nameof(McpServerHandlers.CallToolWithAlternateHandler)}/{nameof(McpRequestFilters.CallToolWithAlternateFilters)}). Use one style or the other.");
}
// Handle tools provided via DI by augmenting the list handler.
@@ -1202,32 +1108,32 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false)
listToolsHandler = BuildFilterPipeline(listToolsHandler, options.Filters.Request.ListToolsFilters);
- // Build the unified task-augmented handler from one of the two paths.
- if (hasTaskPath)
+ // Build the unified alternate-result handler from one of the two paths.
+ if (hasAlternatePath)
{
- // Case 2: task filter + task handler
- callToolWithTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams));
+ // Case 2: alternate filter + alternate handler
+ callToolWithAlternateHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams));
// Augment with DI tools.
if (tools is not null)
{
- var originalHandler = callToolWithTaskHandler;
- callToolWithTaskHandler = (request, cancellationToken) =>
+ var originalHandler = callToolWithAlternateHandler;
+ callToolWithAlternateHandler = (request, cancellationToken) =>
{
if (request.MatchedPrimitive is McpServerTool tool)
{
- return InvokeToolAsTask(tool, request, cancellationToken);
+ return InvokeToolWithAlternate(tool, request, cancellationToken);
}
return originalHandler(request, cancellationToken);
};
}
- callToolWithTaskHandler = BuildFilterPipeline(callToolWithTaskHandler, callToolWithTaskFilters, BuildInitialTaskToolFilter(tools));
+ callToolWithAlternateHandler = BuildFilterPipeline(callToolWithAlternateHandler, callToolWithAlternateFilters, BuildInitialAlternateToolFilter(tools));
}
else
{
- // Case 1: non-task filter + non-task handler → apply filters, then convert to task-based
+ // Case 1: non-alternate filter + non-alternate handler -> apply filters, then convert to alternate-based
callToolHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams));
// Augment with DI tools.
@@ -1247,114 +1153,11 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false)
callToolHandler = BuildFilterPipeline(callToolHandler, callToolFilters, BuildInitialCallToolFilter(tools));
- // Convert to task-based.
+ // Convert to alternate-based.
var finalCallToolHandler = callToolHandler;
- callToolWithTaskHandler = async (request, cancellationToken) =>
+ callToolWithAlternateHandler = async (request, cancellationToken) =>
await finalCallToolHandler(request, cancellationToken).ConfigureAwait(false);
}
-
- // If a task store is configured, wrap so that when the client signals task support
- // the tool execution is offloaded to the background via the store.
- if (options.TaskStore is { } taskStore)
- {
- var innerTaskHandler = callToolWithTaskHandler;
- callToolWithTaskHandler = async (request, cancellationToken) =>
- {
- // The SEP-2663 Tasks extension requires the 2026-07-28 or later revision: the task wire shapes we ship do not
- // interoperate with legacy (<= 2025-11-25) peers. Only materialize a task when the
- // request was negotiated under the 2026-07-28 or later revision AND the client opted in; otherwise
- // run the inner handler and return the direct result (best-effort downgrade, which also
- // defends against a non-conformant legacy client that forges the opt-in envelope).
- if (IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest) && HasTaskExtensionOptIn(request.Params?.Meta))
- {
- var taskInfo = await taskStore.CreateTaskAsync(cancellationToken).ConfigureAwait(false);
- var taskId = taskInfo.TaskId;
-
- var cts = new CancellationTokenSource();
- _taskCancellationSources[taskId] = cts;
-
- // Capture the token synchronously before Task.Run dispatches the work.
- // The cancel handler may race with the background runner: whichever side wins
- // the TryRemove call owns disposal. If we accessed cts.Token from inside the
- // lambda after the handler had already disposed cts, we'd hit ObjectDisposedException.
- var taskCancellationToken = cts.Token;
-
- _ = Task.Run(async () =>
- {
- using (CreateMcpTaskScope(taskId, taskStore))
- {
- try
- {
- var augmented = await innerTaskHandler(request, taskCancellationToken).ConfigureAwait(false);
- if (augmented.IsTask)
- {
- // The handler created its own task externally, but the client already holds
- // the store's taskId from the synchronous return below — we can't redirect.
- // Fail the store's task so the client sees a clear error instead of polling forever.
- var error = new JsonRpcErrorDetail
- {
- Code = (int)McpErrorCode.InternalError,
- Message = $"{nameof(McpServerOptions.TaskStore)} is configured and the {nameof(McpServerHandlers.CallToolWithTaskHandler)} returned IsTask = true. Use only one mechanism to create the task.",
- };
- var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.JsonContext.Default.JsonRpcErrorDetail);
- await taskStore.SetFailedAsync(taskId, errorJson).ConfigureAwait(false);
- return;
- }
-
- var resultJson = JsonSerializer.SerializeToElement(augmented.Result!, McpJsonUtilities.JsonContext.Default.CallToolResult);
- await taskStore.SetCompletedAsync(taskId, resultJson).ConfigureAwait(false);
- }
- catch (OperationCanceledException) when (taskCancellationToken.IsCancellationRequested)
- {
- await taskStore.SetCancelledAsync(taskId, CancellationToken.None).ConfigureAwait(false);
- }
- catch (InputRequiredException)
- {
- // MRTR (input requests) cannot be composed with the task-store wrapper for
- // [McpServerTool] methods today: the task ID was already returned synchronously,
- // so we have no way to surface InputRequiredResult to the client retroactively.
- // Fail the task with a clear, actionable error instead of leaking the raw
- // InputRequiredException through the generic catch below.
- var error = new JsonRpcErrorDetail
- {
- Code = (int)McpErrorCode.InvalidRequest,
- Message = "MRTR (input requests) and tasks cannot be composed via [McpServerTool] yet; " +
- $"use {nameof(McpServerHandlers.CallToolWithTaskHandler)} to manage the input-request loop manually within the task body.",
- };
- var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.JsonContext.Default.JsonRpcErrorDetail);
- await taskStore.SetFailedAsync(taskId, errorJson).ConfigureAwait(false);
- }
- catch (Exception ex)
- {
- // SEP-2663 §186: failed.error MUST be a JSON-RPC error object {code, message, data?}.
- // McpProtocolException carries a JSON-RPC ErrorCode and is documented as safe to
- // propagate (Message + ErrorCode). For any other exception type, redact the message
- // and use InternalError (mirrors the redaction in BuildInitialCallToolFilter).
- var error = ex is McpProtocolException mcpEx
- ? new JsonRpcErrorDetail { Code = (int)mcpEx.ErrorCode, Message = mcpEx.Message }
- : new JsonRpcErrorDetail { Code = (int)McpErrorCode.InternalError, Message = "An error occurred while executing the task." };
- var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.JsonContext.Default.JsonRpcErrorDetail);
- await taskStore.SetFailedAsync(taskId, errorJson).ConfigureAwait(false);
- }
- finally
- {
- // Only the side that wins TryRemove disposes cts. This prevents a
- // double-dispose race with the default tasks/cancel handler.
- if (_taskCancellationSources.TryRemove(taskId, out var registeredCts))
- {
- registeredCts.Dispose();
- }
- }
- }
- }, CancellationToken.None);
-
- return ToCreateTaskResult(taskInfo);
- }
-
- return await innerTaskHandler(request, cancellationToken).ConfigureAwait(false);
- };
- }
-
ServerCapabilities.Tools.ListChanged = listChanged;
SetHandler(
@@ -1363,94 +1166,13 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false)
McpJsonUtilities.JsonContext.Default.ListToolsRequestParams,
McpJsonUtilities.JsonContext.Default.ListToolsResult);
- SetTaskAugmentedHandler(
+ SetWithAlternateHandler(
RequestMethods.ToolsCall,
- callToolWithTaskHandler,
+ callToolWithAlternateHandler,
McpJsonUtilities.JsonContext.Default.CallToolRequestParams,
- McpJsonUtilities.JsonContext.Default.CallToolResult,
- McpJsonUtilities.JsonContext.Default.CreateTaskResult);
+ McpJsonUtilities.JsonContext.Default.CallToolResult);
}
-
- 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,
- ResultType = "task",
- };
-
- 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,
- // McpTaskInfo.InputRequests is IReadOnlyDictionary (covers immutable store
- // implementations like InMemoryMcpTaskStore's ImmutableDictionary), while the wire
- // DTO uses IDictionary like every other Protocol type. Most concrete stores back
- // their dictionaries with a type that implements both interfaces (Dictionary,
- // ImmutableDictionary, ConcurrentDictionary), so the cast usually succeeds and we
- // only allocate a copy as a fallback.
- InputRequests = info.InputRequests is IDictionary dict
- ? dict
- : info.InputRequests?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)
- ?? new Dictionary(),
- ResultType = "complete",
- },
- _ => throw new InvalidOperationException($"Unknown task status: {info.Status}"),
- };
-
- private static async ValueTask> InvokeToolAsTask(
+ private static async ValueTask> InvokeToolWithAlternate(
McpServerTool tool,
RequestContext request,
CancellationToken cancellationToken)
@@ -1501,7 +1223,7 @@ private McpRequestFilter BuildInitialCall
}
};
- private McpRequestFilter> BuildInitialTaskToolFilter(
+ private McpRequestFilter> BuildInitialAlternateToolFilter(
McpServerPrimitiveCollection? tools) => handler =>
async (request, cancellationToken) =>
{
@@ -1514,7 +1236,7 @@ private McpRequestFilter(
requestTypeInfo, responseTypeInfo);
}
- private void SetTaskAugmentedHandler(
+ private void SetWithAlternateHandler(
string method,
- McpRequestHandler> handler,
+ McpRequestHandler> handler,
JsonTypeInfo requestTypeInfo,
- JsonTypeInfo responseTypeInfo,
- JsonTypeInfo taskResultTypeInfo)
+ JsonTypeInfo responseTypeInfo)
where TResult : Result
{
- _requestHandlers.SetTaskAugmented(method,
+ _requestHandlers.SetWithAlternate(method,
(request, jsonRpcRequest, cancellationToken) =>
InvokeHandlerAsync(handler, request, jsonRpcRequest, cancellationToken),
- requestTypeInfo, responseTypeInfo, taskResultTypeInfo);
+ requestTypeInfo, responseTypeInfo);
}
private static McpRequestHandler BuildFilterPipeline(
@@ -1704,15 +1425,6 @@ private static McpRequestHandler BuildFilterPipeline
- meta is not null &&
- meta[MetaKeys.ClientCapabilities] is JsonObject caps &&
- caps["extensions"] is JsonObject exts &&
- exts.ContainsKey(McpExtensions.Tasks);
-
private JsonRpcMessageFilter BuildMessageFilterPipeline(IList filters)
{
if (filters.Count == 0)
@@ -1787,12 +1499,10 @@ internal static LoggingLevel ToLoggingLevel(LogLevel level) =>
///
internal bool HasStatefulTransport() =>
_sessionTransport is not StreamableHttpServerTransport { Stateless: true };
-
///
/// Returns when the given request was negotiated under the 2026-07-28 or later protocol
/// revision, derived from the per-request _meta/MCP-Protocol-Version value (so it works
/// for requests over stateless HTTP) and falling back to the session-negotiated version.
- /// Used to gate the SEP-2663 Tasks extension, which only interoperates on the 2026-07-28 revision.
///
private bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request) =>
McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(
diff --git a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs
index eb99913d5..ad3758151 100644
--- a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs
+++ b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs
@@ -1,7 +1,7 @@
using ModelContextProtocol.Protocol;
using System.Diagnostics.CodeAnalysis;
-#pragma warning disable MCPEXP001
+#pragma warning disable MCPEXP001, MCPEXP002
namespace ModelContextProtocol.Server;
@@ -191,17 +191,18 @@ public McpServerFilters Filters
public int MaxSamplingOutputTokens { get; set; } = 1000;
///
- /// Gets or sets the task store for managing asynchronous task executions.
+ /// Gets or sets custom request handlers to register with the server.
///
///
///
- /// When set, the server automatically enables the io.modelcontextprotocol/tasks extension
- /// and wires up tasks/get, tasks/update, and tasks/cancel handlers backed by this store.
- /// Tool executions from clients that signal task support will be wrapped in tasks via the store.
+ /// Each registers a raw JSON-RPC method handler that
+ /// bypasses the typed handler infrastructure. This enables extensions to register handlers
+ /// for methods not known to Core at compile time.
///
///
- /// If explicit task handlers are also set on , the explicit handlers take precedence.
+ /// Handlers registered here take precedence over built-in handlers for the same method.
///
///
- public IMcpTaskStore? TaskStore { get; set; }
+ [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)]
+ public IList? RequestHandlers { get; set; }
}
diff --git a/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs b/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs
new file mode 100644
index 000000000..be845f599
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs
@@ -0,0 +1,35 @@
+using ModelContextProtocol.Protocol;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Server;
+
+///
+/// Represents a custom request handler that can be registered with the MCP server to handle
+/// arbitrary JSON-RPC methods.
+///
+///
+///
+/// Custom request handlers are registered via and
+/// are invoked when a JSON-RPC request with the matching is received.
+/// The handler receives the raw and returns a serialized
+/// response, giving extensions full control over request/response serialization.
+///
+///
+[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)]
+public sealed class McpServerRequestHandler
+{
+ ///
+ /// Gets the JSON-RPC method name this handler responds to.
+ ///
+ public required string Method { get; init; }
+
+ ///
+ /// Gets the handler function that processes incoming requests for the specified method.
+ ///
+ ///
+ /// The handler receives the full and a ,
+ /// and returns a serialized response (or for void methods).
+ ///
+ public required Func> Handler { get; init; }
+}
diff --git a/src/ModelContextProtocol.Core/Server/McpTaskExecutionContext.cs b/src/ModelContextProtocol.Core/Server/McpTaskExecutionContext.cs
deleted file mode 100644
index 749f36517..000000000
--- a/src/ModelContextProtocol.Core/Server/McpTaskExecutionContext.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-namespace ModelContextProtocol.Server;
-
-///
-/// Provides ambient context when a tool is executing as a background task.
-/// When established, calls to ,
-/// ,
-/// and
-/// are redirected through the task store as input requests rather than sent directly to the client.
-///
-internal sealed class McpTaskExecutionContext
-{
- internal static readonly AsyncLocal Current = new();
-
- public required string TaskId { get; init; }
- public required IMcpTaskStore Store { get; init; }
-
- internal sealed class Scope(McpTaskExecutionContext? previous) : IDisposable
- {
- public void Dispose() => Current.Value = previous;
- }
-}
diff --git a/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs
new file mode 100644
index 000000000..43dd0c2ee
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs
@@ -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;
+
+///
+/// Extension methods for task-aware client operations.
+///
+public static class McpTasksClientExtensions
+{
+ ///
+ /// Calls a tool and returns either an immediate result or a created task.
+ ///
+ public static async ValueTask> 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()),
+ };
+
+ 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(), "task", StringComparison.Ordinal))
+ {
+ var taskCreated = resultObj.Deserialize(McpTasksJsonContext.Default.CreateTaskResult)
+ ?? throw new JsonException("Failed to deserialize CreateTaskResult from response.");
+ return new ResultOrCreatedTask(taskCreated);
+ }
+
+ var callToolResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions.GetTypeInfo())
+ ?? throw new JsonException("Failed to deserialize CallToolResult from response.");
+ return new ResultOrCreatedTask(callToolResult);
+ }
+
+ ///
+ /// Calls a tool and, if needed, polls the created task to completion.
+ ///
+ public static async ValueTask 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);
+ }
+
+ ///
+ /// Retrieves a task by ID.
+ ///
+ public static ValueTask 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);
+ }
+
+ ///
+ /// Retrieves a task using explicit request parameters.
+ ///
+ public static ValueTask 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(
+ TasksProtocol.MethodTasksGet,
+ requestParams,
+ McpTasksJsonContext.Default.Options,
+ cancellationToken: cancellationToken);
+ }
+
+ ///
+ /// Updates a task with input responses.
+ ///
+ public static async ValueTask 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>());
+ }
+
+ 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();
+ }
+
+ ///
+ /// Requests task cancellation by ID.
+ ///
+ public static ValueTask 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);
+ }
+
+ ///
+ /// Requests task cancellation using explicit request parameters.
+ ///
+ public static ValueTask 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(
+ TasksProtocol.MethodTasksCancel,
+ requestParams,
+ McpTasksJsonContext.Default.Options,
+ cancellationToken: cancellationToken);
+ }
+
+ private static async ValueTask PollTaskToCompletionAsync(
+ McpClient client,
+ CreateTaskResult taskCreated,
+ int maxConsecutiveStuckPolls,
+ CancellationToken cancellationToken)
+ {
+ string taskId = taskCreated.TaskId;
+ long pollIntervalMs = taskCreated.PollIntervalMs ?? 1000;
+ HashSet? 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())
+ ?? 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();
+ 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 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(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) =>
+ McpHttpHeaders.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 '{McpHttpHeaders.July2026ProtocolVersion}' revision or later). " +
+ $"The negotiated protocol version is '{client.NegotiatedProtocolVersion ?? "(none)"}'.");
+ }
+ }
+}
diff --git a/src/ModelContextProtocol.Extensions.Tasks/McpTasksJsonContext.cs b/src/ModelContextProtocol.Extensions.Tasks/McpTasksJsonContext.cs
new file mode 100644
index 000000000..13c92ee14
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Tasks/McpTasksJsonContext.cs
@@ -0,0 +1,32 @@
+using System.Text.Json.Serialization;
+using ModelContextProtocol.Protocol;
+
+namespace ModelContextProtocol.Extensions.Tasks;
+
+///
+/// Provides source-generated JSON serialization metadata for MCP Tasks extension types.
+///
+[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
+{
+}
diff --git a/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj b/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj
new file mode 100644
index 000000000..e2b52f106
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj
@@ -0,0 +1,51 @@
+
+
+
+ net10.0;net9.0;net8.0;netstandard2.0
+ true
+ true
+ ModelContextProtocol.Extensions.Tasks
+ MCP Tasks extension for the .NET Model Context Protocol (MCP) SDK
+ README.md
+
+ $(NoWarn);MCPEXP001;MCPEXP002
+
+ false
+
+
+
+ true
+
+
+
+
+ $(NoWarn);CS0436
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ModelContextProtocol.Core/Protocol/CancelTaskRequestParams.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskRequestParams.cs
similarity index 92%
rename from src/ModelContextProtocol.Core/Protocol/CancelTaskRequestParams.cs
rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskRequestParams.cs
index ee458d064..f4d6ff69f 100644
--- a/src/ModelContextProtocol.Core/Protocol/CancelTaskRequestParams.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskRequestParams.cs
@@ -1,6 +1,7 @@
+using ModelContextProtocol.Protocol;
using System.Text.Json.Serialization;
-namespace ModelContextProtocol.Protocol;
+namespace ModelContextProtocol.Extensions.Tasks;
///
/// Represents the parameters for a tasks/cancel request to signal intent to cancel an in-progress task.
diff --git a/src/ModelContextProtocol.Core/Protocol/CancelTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskResult.cs
similarity index 90%
rename from src/ModelContextProtocol.Core/Protocol/CancelTaskResult.cs
rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskResult.cs
index 4d066862b..c9d92a06e 100644
--- a/src/ModelContextProtocol.Core/Protocol/CancelTaskResult.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskResult.cs
@@ -1,6 +1,7 @@
+using ModelContextProtocol.Protocol;
using System.Text.Json.Serialization;
-namespace ModelContextProtocol.Protocol;
+namespace ModelContextProtocol.Extensions.Tasks;
///
/// Represents the result of a tasks/cancel request. This is an empty acknowledgement.
diff --git a/src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CreateTaskResult.cs
similarity index 96%
rename from src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs
rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/CreateTaskResult.cs
index 6d9bac23c..9e262e5c0 100644
--- a/src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CreateTaskResult.cs
@@ -1,8 +1,9 @@
+using ModelContextProtocol.Protocol;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
-namespace ModelContextProtocol.Protocol;
+namespace ModelContextProtocol.Extensions.Tasks;
///
/// Represents the result returned by a server when it creates a task in lieu of a standard result.
diff --git a/src/ModelContextProtocol.Core/Protocol/GetTaskRequestParams.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskRequestParams.cs
similarity index 90%
rename from src/ModelContextProtocol.Core/Protocol/GetTaskRequestParams.cs
rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskRequestParams.cs
index 52b82d902..a865bf690 100644
--- a/src/ModelContextProtocol.Core/Protocol/GetTaskRequestParams.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskRequestParams.cs
@@ -1,6 +1,7 @@
+using ModelContextProtocol.Protocol;
using System.Text.Json.Serialization;
-namespace ModelContextProtocol.Protocol;
+namespace ModelContextProtocol.Extensions.Tasks;
///
/// Represents the parameters for a tasks/get request to poll for task completion.
diff --git a/src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskResult.cs
similarity index 98%
rename from src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs
rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskResult.cs
index 3fea8cc6b..4d083d8a1 100644
--- a/src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskResult.cs
@@ -1,9 +1,9 @@
-using System.Diagnostics.CodeAnalysis;
+using ModelContextProtocol.Protocol;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
-namespace ModelContextProtocol.Protocol;
+namespace ModelContextProtocol.Extensions.Tasks;
///
/// Represents the result of a tasks/get request, containing the full task state.
@@ -169,7 +169,7 @@ internal sealed class Converter : JsonConverter
}
string requestKey = reader.GetString()!;
reader.Read();
- var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.InputRequest)
+ var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.DefaultOptions.GetTypeInfo())
?? throw new JsonException($"Failed to deserialize InputRequest for key '{requestKey}'.");
inputRequests[requestKey] = inputRequest;
}
@@ -315,7 +315,7 @@ public override void Write(Utf8JsonWriter writer, GetTaskResult value, JsonSeria
foreach (var kvp in reqs)
{
writer.WritePropertyName(kvp.Key);
- JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.JsonContext.Default.InputRequest);
+ JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.DefaultOptions.GetTypeInfo());
}
}
writer.WriteEndObject();
diff --git a/src/ModelContextProtocol.Core/Protocol/McpTaskStatus.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/McpTaskStatus.cs
similarity index 94%
rename from src/ModelContextProtocol.Core/Protocol/McpTaskStatus.cs
rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/McpTaskStatus.cs
index 3b705a947..90ee12d27 100644
--- a/src/ModelContextProtocol.Core/Protocol/McpTaskStatus.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/McpTaskStatus.cs
@@ -1,6 +1,7 @@
+using ModelContextProtocol.Protocol;
using System.Text.Json.Serialization;
-namespace ModelContextProtocol.Protocol;
+namespace ModelContextProtocol.Extensions.Tasks;
///
/// Represents the status of an MCP task.
diff --git a/src/ModelContextProtocol.Core/Protocol/ResultOrCreatedTask.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/ResultOrCreatedTask.cs
similarity index 91%
rename from src/ModelContextProtocol.Core/Protocol/ResultOrCreatedTask.cs
rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/ResultOrCreatedTask.cs
index 87b857470..1e9100c67 100644
--- a/src/ModelContextProtocol.Core/Protocol/ResultOrCreatedTask.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/ResultOrCreatedTask.cs
@@ -1,4 +1,5 @@
-namespace ModelContextProtocol.Protocol;
+using ModelContextProtocol.Protocol;
+namespace ModelContextProtocol.Extensions.Tasks;
///
/// Represents the result of a request that supports task-augmented execution, which may be either
@@ -31,7 +32,10 @@ public class ResultOrCreatedTask where TResult : Result
/// The standard result returned by the server.
public ResultOrCreatedTask(TResult result)
{
- Throw.IfNull(result);
+ if (result is null)
+ {
+ throw new ArgumentNullException(nameof(result));
+ }
_result = result;
}
@@ -41,7 +45,10 @@ public ResultOrCreatedTask(TResult result)
/// The task creation result returned by the server.
public ResultOrCreatedTask(CreateTaskResult taskCreated)
{
- Throw.IfNull(taskCreated);
+ if (taskCreated is null)
+ {
+ throw new ArgumentNullException(nameof(taskCreated));
+ }
_taskCreated = taskCreated;
}
diff --git a/src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/TaskStatusNotificationParams.cs
similarity index 98%
rename from src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs
rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/TaskStatusNotificationParams.cs
index 4e859a22c..020896a1f 100644
--- a/src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/TaskStatusNotificationParams.cs
@@ -1,9 +1,9 @@
-using System.Diagnostics.CodeAnalysis;
+using ModelContextProtocol.Protocol;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
-namespace ModelContextProtocol.Protocol;
+namespace ModelContextProtocol.Extensions.Tasks;
///
/// Represents the parameters for a notifications/tasks notification sent by the server
@@ -171,7 +171,7 @@ internal sealed class Converter : JsonConverter
}
string requestKey = reader.GetString()!;
reader.Read();
- var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.InputRequest)
+ var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.DefaultOptions.GetTypeInfo())
?? throw new JsonException($"Failed to deserialize InputRequest for key '{requestKey}'.");
inputRequests[requestKey] = inputRequest;
}
@@ -311,7 +311,7 @@ public override void Write(Utf8JsonWriter writer, TaskStatusNotificationParams v
foreach (var kvp in reqs)
{
writer.WritePropertyName(kvp.Key);
- JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.JsonContext.Default.InputRequest);
+ JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.DefaultOptions.GetTypeInfo());
}
}
writer.WriteEndObject();
@@ -390,4 +390,3 @@ public sealed class InputRequiredTaskNotificationParams : TaskStatusNotification
[JsonPropertyName("inputRequests")]
public IDictionary? InputRequests { get; set; }
}
-
diff --git a/src/ModelContextProtocol.Core/Protocol/UpdateTaskRequestParams.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskRequestParams.cs
similarity index 93%
rename from src/ModelContextProtocol.Core/Protocol/UpdateTaskRequestParams.cs
rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskRequestParams.cs
index 07b45de15..aeeb2c79a 100644
--- a/src/ModelContextProtocol.Core/Protocol/UpdateTaskRequestParams.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskRequestParams.cs
@@ -1,6 +1,7 @@
+using ModelContextProtocol.Protocol;
using System.Text.Json.Serialization;
-namespace ModelContextProtocol.Protocol;
+namespace ModelContextProtocol.Extensions.Tasks;
///
/// Represents the parameters for a tasks/update request to provide input responses
diff --git a/src/ModelContextProtocol.Core/Protocol/UpdateTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskResult.cs
similarity index 88%
rename from src/ModelContextProtocol.Core/Protocol/UpdateTaskResult.cs
rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskResult.cs
index 531039c23..b9f59f395 100644
--- a/src/ModelContextProtocol.Core/Protocol/UpdateTaskResult.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskResult.cs
@@ -1,6 +1,7 @@
+using ModelContextProtocol.Protocol;
using System.Text.Json.Serialization;
-namespace ModelContextProtocol.Protocol;
+namespace ModelContextProtocol.Extensions.Tasks;
///
/// Represents the result of a tasks/update request. This is an empty acknowledgement.
diff --git a/src/ModelContextProtocol.Core/Server/IMcpTaskStore.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/IMcpTaskStore.cs
similarity index 99%
rename from src/ModelContextProtocol.Core/Server/IMcpTaskStore.cs
rename to src/ModelContextProtocol.Extensions.Tasks/Server/IMcpTaskStore.cs
index 337cb1946..6851f21ac 100644
--- a/src/ModelContextProtocol.Core/Server/IMcpTaskStore.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Server/IMcpTaskStore.cs
@@ -1,7 +1,7 @@
using ModelContextProtocol.Protocol;
using System.Text.Json;
-namespace ModelContextProtocol.Server;
+namespace ModelContextProtocol.Extensions.Tasks;
///
/// Provides an interface for storing and managing the lifecycle of MCP tasks.
diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/InMemoryMcpTaskStore.cs
similarity index 99%
rename from src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs
rename to src/ModelContextProtocol.Extensions.Tasks/Server/InMemoryMcpTaskStore.cs
index 762598226..1b8b61246 100644
--- a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Server/InMemoryMcpTaskStore.cs
@@ -3,7 +3,7 @@
using System.Collections.Immutable;
using System.Text.Json;
-namespace ModelContextProtocol.Server;
+namespace ModelContextProtocol.Extensions.Tasks;
///
/// Provides an in-memory implementation of for development and testing scenarios.
diff --git a/src/ModelContextProtocol.Core/Server/InputResponseReceivedEventArgs.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/InputResponseReceivedEventArgs.cs
similarity index 92%
rename from src/ModelContextProtocol.Core/Server/InputResponseReceivedEventArgs.cs
rename to src/ModelContextProtocol.Extensions.Tasks/Server/InputResponseReceivedEventArgs.cs
index 14447bd6f..c1cedf6d1 100644
--- a/src/ModelContextProtocol.Core/Server/InputResponseReceivedEventArgs.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Server/InputResponseReceivedEventArgs.cs
@@ -1,6 +1,6 @@
using ModelContextProtocol.Protocol;
-namespace ModelContextProtocol.Server;
+namespace ModelContextProtocol.Extensions.Tasks;
///
/// Provides data for the event.
diff --git a/src/ModelContextProtocol.Core/Server/McpTaskInfo.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskInfo.cs
similarity index 94%
rename from src/ModelContextProtocol.Core/Server/McpTaskInfo.cs
rename to src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskInfo.cs
index d275fb3a6..a9a86ed54 100644
--- a/src/ModelContextProtocol.Core/Server/McpTaskInfo.cs
+++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskInfo.cs
@@ -1,7 +1,7 @@
using ModelContextProtocol.Protocol;
using System.Text.Json;
-namespace ModelContextProtocol.Server;
+namespace ModelContextProtocol.Extensions.Tasks;
///
/// Represents the state of a task in an .
diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs
new file mode 100644
index 000000000..ec01e86bd
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs
@@ -0,0 +1,301 @@
+using Microsoft.Extensions.DependencyInjection;
+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;
+
+///
+/// Extension methods for to enable MCP Tasks support.
+///
+public static class McpTasksBuilderExtensions
+{
+ ///
+ /// Enables MCP Tasks support backed by the specified task store.
+ ///
+ /// The server builder.
+ /// The task store.
+ /// The builder provided in .
+ 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
+
+ builder.Services.AddSingleton>(new McpTasksPostConfigureOptions(store));
+ return builder;
+ }
+
+ private sealed class McpTasksPostConfigureOptions(IMcpTaskStore store) : IPostConfigureOptions
+ {
+ private readonly IMcpTaskStore _store = store;
+ private readonly ConcurrentDictionary _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();
+ if (!options.Capabilities.Extensions.ContainsKey(TasksProtocol.ExtensionId))
+ {
+ options.Capabilities.Extensions[TasksProtocol.ExtensionId] = new JsonObject();
+ }
+
+ options.RequestHandlers ??= new List();
+ 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 () =>
+ {
+ 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());
+ await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false);
+ return;
+ }
+
+ var resultJson = JsonSerializer.SerializeToElement(augmented.Result!, McpJsonUtilities.DefaultOptions.GetTypeInfo());
+ 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());
+ 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());
+ 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());
+ await _store.SetCompletedAsync(taskId, resultJson).ConfigureAwait(false);
+ }
+ finally
+ {
+ if (_cancellationSources.TryRemove(taskId, out var registeredCts))
+ {
+ registeredCts.Dispose();
+ }
+ }
+ }
+ }, CancellationToken.None);
+
+ return new ResultOrAlternate(
+ ToCreateTaskResult(taskInfo),
+ McpTasksJsonContext.Default.CreateTaskResult);
+ }
+
+ return await next(request, cancellationToken).ConfigureAwait(false);
+ });
+ }
+
+ private async ValueTask 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 HandleUpdateTask(JsonRpcRequest request, CancellationToken cancellationToken)
+ {
+ GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksUpdate);
+
+ var taskId = request.Params?["taskId"]?.GetValue()
+ ?? 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>())
+ ?? new Dictionary();
+
+ await _store.ResolveInputRequestsAsync(taskId, inputResponses, cancellationToken).ConfigureAwait(false);
+
+ return JsonSerializer.SerializeToNode(new UpdateTaskResult(), McpTasksJsonContext.Default.UpdateTaskResult);
+ }
+
+ private async ValueTask 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 '{McpHttpHeaders.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) =>
+ McpHttpHeaders.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,
+ ResultType = "task",
+ };
+
+ 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 dict
+ ? dict
+ : info.InputRequests?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) ?? new Dictionary(),
+ ResultType = "complete",
+ },
+ _ => throw new InvalidOperationException($"Unknown task status: {info.Status}"),
+ };
+ }
+}
diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksServerExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksServerExtensions.cs
new file mode 100644
index 000000000..31445fd1f
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksServerExtensions.cs
@@ -0,0 +1,109 @@
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Extensions.Tasks;
+
+///
+/// Extension methods for task-aware server operations.
+///
+public static class McpTasksServerExtensions
+{
+ ///
+ /// Sends a task status notification to the connected client.
+ ///
+ /// The server sending the notification.
+ /// The notification payload.
+ /// The cancellation token.
+ /// A task representing the send operation.
+ 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);
+ }
+
+ ///
+ /// Creates a scope that routes server-initiated requests through the specified task store.
+ ///
+ /// The server whose outgoing requests should be redirected.
+ /// The related task identifier.
+ /// The task store used to surface input requests.
+ /// An that restores the previous outgoing-request behavior.
+ 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()),
+ };
+
+ var tcs = new TaskCompletionSource(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 { [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;
+ }
+ });
+ }
+}
diff --git a/src/ModelContextProtocol.Extensions.Tasks/TasksProtocol.cs b/src/ModelContextProtocol.Extensions.Tasks/TasksProtocol.cs
new file mode 100644
index 000000000..44172666a
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Tasks/TasksProtocol.cs
@@ -0,0 +1,37 @@
+namespace ModelContextProtocol.Extensions.Tasks;
+
+///
+/// Provides constants for the MCP Tasks extension (SEP-2663).
+///
+public static class TasksProtocol
+{
+ ///
+ /// The extension identifier for the MCP Tasks extension.
+ ///
+ public const string ExtensionId = "io.modelcontextprotocol/tasks";
+
+ ///
+ /// The name of the request method sent from the client to poll for task completion.
+ ///
+ public const string MethodTasksGet = "tasks/get";
+
+ ///
+ /// The name of the request method sent from the client to provide input responses to a task.
+ ///
+ public const string MethodTasksUpdate = "tasks/update";
+
+ ///
+ /// The name of the request method sent from the client to signal intent to cancel a task.
+ ///
+ public const string MethodTasksCancel = "tasks/cancel";
+
+ ///
+ /// The name of the notification sent by the server when a task's status changes.
+ ///
+ public const string NotificationTaskStatus = "notifications/tasks/status";
+
+ ///
+ /// The metadata key used to associate requests, responses, and notifications with a task.
+ ///
+ public const string MetaRelatedTask = "io.modelcontextprotocol/related-task";
+}
diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs
index 879173819..af1334fab 100644
--- a/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs
+++ b/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs
@@ -1,3 +1,4 @@
+using ModelContextProtocol.Extensions.Tasks;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
@@ -11,7 +12,7 @@ namespace ModelContextProtocol.Tests.Client;
///
/// Integration tests for the client-side task API methods: GetTaskAsync, CancelTaskAsync,
-/// UpdateTaskAsync, CallToolRawAsync, and the automatic polling in CallToolAsync.
+/// UpdateTaskAsync, CallToolAsTaskAsync, and the automatic polling in CallToolWithPollingAsync.
///
public class McpClientTaskMethodsTests : ClientServerTestBase
{
@@ -25,15 +26,12 @@ public McpClientTaskMethodsTests(ITestOutputHelper outputHelper)
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
{
- mcpServerBuilder.Services.Configure(options =>
- {
- options.TaskStore = new InMemoryMcpTaskStore
+ mcpServerBuilder
+ .WithTasks(new InMemoryMcpTaskStore
{
DefaultPollIntervalMs = 50,
- };
- });
-
- mcpServerBuilder.WithTools([McpServerTool.Create(
+ })
+ .WithTools([McpServerTool.Create(
async (string input, CancellationToken ct) =>
{
await Task.Delay(50, ct);
@@ -60,7 +58,7 @@ public async Task GetTaskAsync_ReturnsTaskStatus()
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams
{
Name = "test-tool",
@@ -97,12 +95,12 @@ await Assert.ThrowsAsync(async () =>
}
[Fact]
- public async Task CallToolRawAsync_WithTaskStore_ReturnsCreatedTask()
+ public async Task CallToolAsTaskAsync_WithTaskStore_ReturnsCreatedTask()
{
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams
{
Name = "test-tool",
@@ -122,12 +120,12 @@ public async Task CallToolAsync_PollsUntilCompletion_ReturnsResult()
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
- var result = await client.CallToolAsync(
+ var result = await client.CallToolWithPollingAsync(
new CallToolRequestParams
{
Name = "test-tool",
Arguments = CreateArguments("input", "hello"),
- }, ct);
+ }, cancellationToken: ct);
Assert.NotNull(result);
Assert.NotEmpty(result.Content);
@@ -141,7 +139,7 @@ public async Task CancelTaskAsync_ForWorkingTask_Succeeds()
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams
{
Name = "test-tool",
@@ -172,7 +170,7 @@ public async Task CancelTaskAsync_NullTaskId_Throws()
await using var client = await CreateMcpClientForServer();
await Assert.ThrowsAsync(async () =>
- await client.CancelTaskAsync((string)null!, TestContext.Current.CancellationToken));
+ await client.CancelTaskAsync((string)null!, cancellationToken: TestContext.Current.CancellationToken));
}
[Fact]
@@ -194,7 +192,7 @@ public async Task GetTaskAsync_AfterCompletion_ReturnsCompletedResult()
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams
{
Name = "test-tool",
@@ -235,7 +233,7 @@ public async Task MultipleTasks_CreatedConcurrently_HaveUniqueIds()
for (int i = 0; i < 5; i++)
{
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams
{
Name = "test-tool",
diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj
index 4b782cd64..c9fdff85f 100644
--- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj
+++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj
@@ -83,6 +83,7 @@
+
diff --git a/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs
index 86acc57f6..da4afb62f 100644
--- a/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs
+++ b/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs
@@ -1,3 +1,4 @@
+using ModelContextProtocol.Extensions.Tasks;
using ModelContextProtocol.Protocol;
using System.Text.Json;
using System.Text.Json.Nodes;
@@ -27,8 +28,8 @@ public static void CreateTaskResult_SerializationRoundTrip_PreservesAllPropertie
Meta = new JsonObject { ["key"] = "value" }
};
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+ string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options);
+ var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options);
Assert.NotNull(deserialized);
Assert.Equal("task-123", deserialized.TaskId);
@@ -57,7 +58,7 @@ public static void CreateTaskResult_UsesCorrectWireFieldNames()
ResultType = "task",
};
- string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions);
+ string json = JsonSerializer.Serialize(result, McpTasksJsonContext.Default.Options);
// Must use camelCase wire names
Assert.Contains("\"ttlMs\":", json);
@@ -82,7 +83,7 @@ public static void CreateTaskResult_ResultType_SerializesAsTask()
ResultType = "task",
};
- string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions);
+ string json = JsonSerializer.Serialize(result, McpTasksJsonContext.Default.Options);
var node = JsonNode.Parse(json)!;
Assert.Equal("task", (string)node["resultType"]!);
@@ -104,8 +105,8 @@ public static void GetTaskResult_Working_RoundTrip()
PollIntervalMs = 2000,
};
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+ string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options);
+ var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options);
var working = Assert.IsType(deserialized);
Assert.Equal("w1", working.TaskId);
@@ -126,8 +127,8 @@ public static void GetTaskResult_Completed_RoundTrip_IncludesResult()
Result = resultPayload,
};
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+ string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options);
+ var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options);
var completed = Assert.IsType(deserialized);
Assert.Equal("c1", completed.TaskId);
@@ -147,8 +148,8 @@ public static void GetTaskResult_Failed_RoundTrip_IncludesError()
Error = errorPayload,
};
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+ string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options);
+ var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options);
var failed = Assert.IsType(deserialized);
Assert.Equal("f1", failed.TaskId);
@@ -167,8 +168,8 @@ public static void GetTaskResult_Cancelled_RoundTrip()
StatusMessage = "User cancelled",
};
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+ string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options);
+ var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options);
var cancelled = Assert.IsType(deserialized);
Assert.Equal("x1", cancelled.TaskId);
@@ -195,8 +196,8 @@ public static void GetTaskResult_InputRequired_RoundTrip_IncludesInputRequests()
InputRequests = inputRequests,
};
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+ string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options);
+ var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options);
var inputRequired = Assert.IsType(deserialized);
Assert.Equal("i1", inputRequired.TaskId);
@@ -228,7 +229,7 @@ public static void GetTaskResult_Converter_DispatchesToCorrectSubtypeByStatus()
_ => $$$"""{"taskId":"t","status":"{{{status}}}","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}""",
};
- var result = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+ var result = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options);
Assert.NotNull(result);
Assert.IsType(expectedType, result);
}
@@ -238,42 +239,42 @@ public static void GetTaskResult_Converter_DispatchesToCorrectSubtypeByStatus()
public static void GetTaskResult_MissingTaskId_ThrowsJsonException()
{
var json = """{"status":"working","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}""";
- Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions));
+ Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options));
}
[Fact]
public static void GetTaskResult_MissingStatus_ThrowsJsonException()
{
var json = """{"taskId":"t","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}""";
- Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions));
+ Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options));
}
[Fact]
public static void GetTaskResult_UnknownStatus_ThrowsJsonException()
{
var json = """{"taskId":"t","status":"exploded","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}""";
- Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions));
+ Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options));
}
[Fact]
public static void GetTaskResult_CompletedMissingResult_ThrowsJsonException()
{
var json = """{"taskId":"t","status":"completed","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}""";
- Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions));
+ Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options));
}
[Fact]
public static void GetTaskResult_FailedMissingError_ThrowsJsonException()
{
var json = """{"taskId":"t","status":"failed","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}""";
- Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions));
+ Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options));
}
[Fact]
public static void GetTaskResult_InputRequiredMissingInputRequests_ThrowsJsonException()
{
var json = """{"taskId":"t","status":"input_required","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}""";
- Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions));
+ Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options));
}
[Theory]
@@ -303,14 +304,14 @@ public static void GetTaskResult_WireResultType_IsComplete_WhenSet(Type subType)
["k"] = new InputRequest
{
Method = "test/method",
- Params = JsonSerializer.SerializeToElement("ask", McpJsonUtilities.DefaultOptions),
+ Params = JsonSerializer.SerializeToElement("ask", McpTasksJsonContext.Default.Options),
},
},
},
_ => throw new InvalidOperationException()
};
- string json = JsonSerializer.Serialize(value, McpJsonUtilities.DefaultOptions);
+ string json = JsonSerializer.Serialize(value, McpTasksJsonContext.Default.Options);
var node = JsonNode.Parse(json)!;
Assert.Equal("complete", (string?)node["resultType"]);
@@ -328,10 +329,10 @@ public static void GetTaskResult_WireResultType_IsComplete_WhenSet(Type subType)
[InlineData(McpTaskStatus.Failed, "failed")]
public static void McpTaskStatus_SerializesAsSnakeCase(McpTaskStatus status, string expectedWireValue)
{
- string json = JsonSerializer.Serialize(status, McpJsonUtilities.DefaultOptions);
+ string json = JsonSerializer.Serialize(status, McpTasksJsonContext.Default.Options);
Assert.Equal($"\"{expectedWireValue}\"", json);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+ var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options);
Assert.Equal(status, deserialized);
}
@@ -350,8 +351,8 @@ public static void TaskStatusNotificationParams_Working_RoundTrip()
StatusMessage = "Working on it",
};
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+ string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options);
+ var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options);
var working = Assert.IsType(deserialized);
Assert.Equal("n1", working.TaskId);
@@ -370,8 +371,8 @@ public static void TaskStatusNotificationParams_Completed_RoundTrip()
Result = resultPayload,
};
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+ string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options);
+ var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options);
var completed = Assert.IsType(deserialized);
Assert.Equal("n2", completed.TaskId);
@@ -390,8 +391,8 @@ public static void TaskStatusNotificationParams_Failed_RoundTrip()
Error = errorPayload,
};
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+ string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options);
+ var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options);
var failed = Assert.IsType(deserialized);
Assert.Equal("n3", failed.TaskId);
@@ -408,8 +409,8 @@ public static void TaskStatusNotificationParams_Cancelled_RoundTrip()
LastUpdatedAt = DateTimeOffset.UtcNow,
};
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+ string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options);
+ var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options);
Assert.IsType(deserialized);
}
@@ -429,8 +430,8 @@ public static void TaskStatusNotificationParams_InputRequired_RoundTrip()
InputRequests = inputRequests,
};
- string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
- var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+ string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options);
+ var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options);
var inputRequired = Assert.IsType(deserialized);
Assert.NotNull(inputRequired.InputRequests);
@@ -439,10 +440,10 @@ public static void TaskStatusNotificationParams_InputRequired_RoundTrip()
#endregion
- #region ResultOrCreatedTask
+ #region ResultOrAlternate
[Fact]
- public static void ResultOrCreatedTask_ImplicitConversion_FromResult()
+ public static void ResultOrAlternate_ImplicitConversion_FromResult()
{
CallToolResult callResult = new() { Content = [new TextContentBlock { Text = "hi" }] };
@@ -454,7 +455,7 @@ public static void ResultOrCreatedTask_ImplicitConversion_FromResult()
}
[Fact]
- public static void ResultOrCreatedTask_ImplicitConversion_FromCreateTaskResult()
+ public static void ResultOrAlternate_ImplicitConversion_FromCreateTaskResult()
{
CreateTaskResult taskCreated = new()
{
@@ -472,7 +473,7 @@ public static void ResultOrCreatedTask_ImplicitConversion_FromCreateTaskResult()
}
[Fact]
- public static void ResultOrCreatedTask_IsTask_FalseForResult_TrueForTask()
+ public static void ResultOrAlternate_IsTask_FalseForResult_TrueForTask()
{
var result = new ResultOrCreatedTask(new CallToolResult());
var task = new ResultOrCreatedTask(new CreateTaskResult
@@ -496,7 +497,7 @@ public static void UpdateTaskResult_WireResultType_IsComplete_WhenSet()
{
// SEP-2663: tasks/update responses use resultType="complete".
var result = new UpdateTaskResult { ResultType = "complete" };
- string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions);
+ string json = JsonSerializer.Serialize(result, McpTasksJsonContext.Default.Options);
var node = JsonNode.Parse(json)!;
Assert.Equal("complete", (string?)node["resultType"]);
@@ -507,7 +508,7 @@ public static void CancelTaskResult_WireResultType_IsComplete_WhenSet()
{
// SEP-2663: tasks/cancel responses use resultType="complete".
var result = new CancelTaskResult { ResultType = "complete" };
- string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions);
+ string json = JsonSerializer.Serialize(result, McpTasksJsonContext.Default.Options);
var node = JsonNode.Parse(json)!;
Assert.Equal("complete", (string?)node["resultType"]);
diff --git a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs
index 83afbfe23..5015a5f30 100644
--- a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs
@@ -1,3 +1,4 @@
+using ModelContextProtocol.Extensions.Tasks;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Text.Json;
diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs
index 9708722f3..94bb4dcd0 100644
--- a/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs
@@ -1,3 +1,4 @@
+using ModelContextProtocol.Extensions.Tasks;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using Microsoft.Extensions.DependencyInjection;
@@ -5,6 +6,8 @@
using System.Text.Json;
using System.Text.Json.Nodes;
+#pragma warning disable MCPEXP001, MCPEXP002
+
namespace ModelContextProtocol.Tests.Server;
///
@@ -29,74 +32,97 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer
mcpServerBuilder.Services.Configure(options =>
{
options.Capabilities ??= new ServerCapabilities();
+ options.Capabilities.Extensions ??= new Dictionary();
+ options.Capabilities.Extensions[TasksProtocol.ExtensionId] = new JsonObject();
+ options.RequestHandlers ??= new List();
- options.Handlers.CallToolWithTaskHandler = async (context, cancellationToken) =>
+ options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) =>
{
_capturedMeta = context.Params?.Meta;
var store = context.Server.Services!.GetRequiredService();
var toolName = context.Params!.Name;
- if (toolName == "immediate-tool")
+ ResultOrAlternate result = toolName switch
{
- return new CallToolResult()
+ "immediate-tool" => new(new CallToolResult
{
Content = [new TextContentBlock { Text = "immediate result" }],
- };
- }
+ }),
+ "async-tool" => new(
+ new CreateTaskResult
+ {
+ TaskId = store.CreateTask(),
+ Status = McpTaskStatus.Working,
+ CreatedAt = DateTimeOffset.UtcNow,
+ LastUpdatedAt = DateTimeOffset.UtcNow,
+ PollIntervalMs = 50,
+ ResultType = "task",
+ },
+ McpTasksJsonContext.Default.CreateTaskResult),
+ "input-required-tool" => new(
+ new CreateTaskResult
+ {
+ TaskId = store.CreateTask(McpTaskStatus.InputRequired),
+ Status = McpTaskStatus.InputRequired,
+ CreatedAt = DateTimeOffset.UtcNow,
+ LastUpdatedAt = DateTimeOffset.UtcNow,
+ PollIntervalMs = 50,
+ ResultType = "task",
+ },
+ McpTasksJsonContext.Default.CreateTaskResult),
+ _ => throw new McpException($"Unknown tool: {toolName}"),
+ };
- if (toolName == "async-tool")
- {
- var taskId = store.CreateTask();
- return new CreateTaskResult
- {
- TaskId = taskId,
- Status = McpTaskStatus.Working,
- CreatedAt = DateTimeOffset.UtcNow,
- LastUpdatedAt = DateTimeOffset.UtcNow,
- PollIntervalMs = 50,
- ResultType = "task",
- };
- }
+ return new ValueTask>(result);
+ };
- if (toolName == "input-required-tool")
+ options.RequestHandlers.Add(new McpServerRequestHandler
+ {
+ Method = TasksProtocol.MethodTasksGet,
+ Handler = (request, cancellationToken) =>
{
- var taskId = store.CreateTask(McpTaskStatus.InputRequired);
- return new CreateTaskResult
+ var requestParams = JsonSerializer.Deserialize(request.Params, McpTasksJsonContext.Default.Options)
+ ?? throw new McpProtocolException("Missing params for tasks/get", McpErrorCode.InvalidParams);
+
+ GetTaskResult result;
+ try
{
- TaskId = taskId,
- Status = McpTaskStatus.InputRequired,
- CreatedAt = DateTimeOffset.UtcNow,
- LastUpdatedAt = DateTimeOffset.UtcNow,
- PollIntervalMs = 50,
- ResultType = "task",
- };
- }
+ result = _taskStore.GetTask(requestParams.TaskId);
+ }
+ catch (McpException ex)
+ {
+ throw new McpProtocolException(ex.Message, McpErrorCode.InvalidParams);
+ }
- throw new McpException($"Unknown tool: {toolName}");
- };
+ return new ValueTask(JsonSerializer.SerializeToNode(result, McpTasksJsonContext.Default.Options));
+ },
+ });
- options.Handlers.GetTaskHandler = async (context, cancellationToken) =>
+ options.RequestHandlers.Add(new McpServerRequestHandler
{
- var store = context.Server.Services!.GetRequiredService();
- var taskId = context.Params!.TaskId;
- return store.GetTask(taskId);
- };
+ Method = TasksProtocol.MethodTasksUpdate,
+ Handler = (request, cancellationToken) =>
+ {
+ var requestParams = JsonSerializer.Deserialize(request.Params, McpTasksJsonContext.Default.Options)
+ ?? throw new McpProtocolException("Missing params for tasks/update", McpErrorCode.InvalidParams);
- options.Handlers.UpdateTaskHandler = async (context, cancellationToken) =>
- {
- var store = context.Server.Services!.GetRequiredService();
- var taskId = context.Params!.TaskId;
- store.ProvideInput(taskId, context.Params.InputResponses ?? new Dictionary());
- return new UpdateTaskResult();
- };
+ _taskStore.ProvideInput(requestParams.TaskId, requestParams.InputResponses ?? new Dictionary());
+ return new ValueTask(JsonSerializer.SerializeToNode(new UpdateTaskResult(), McpTasksJsonContext.Default.Options));
+ },
+ });
- options.Handlers.CancelTaskHandler = async (context, cancellationToken) =>
+ options.RequestHandlers.Add(new McpServerRequestHandler
{
- var store = context.Server.Services!.GetRequiredService();
- var taskId = context.Params!.TaskId;
- store.CancelTask(taskId);
- return new CancelTaskResult();
- };
+ Method = TasksProtocol.MethodTasksCancel,
+ Handler = (request, cancellationToken) =>
+ {
+ var requestParams = JsonSerializer.Deserialize(request.Params, McpTasksJsonContext.Default.Options)
+ ?? throw new McpProtocolException("Missing params for tasks/cancel", McpErrorCode.InvalidParams);
+
+ _taskStore.CancelTask(requestParams.TaskId);
+ return new ValueTask(JsonSerializer.SerializeToNode(new CancelTaskResult(), McpTasksJsonContext.Default.Options));
+ },
+ });
});
}
@@ -105,9 +131,9 @@ public async Task CallToolAsync_ImmediateResult_ReturnsDirectly()
{
await using var client = await CreateMcpClientForServer();
- var result = await client.CallToolAsync(
+ var result = await client.CallToolWithPollingAsync(
new CallToolRequestParams { Name = "immediate-tool" },
- TestContext.Current.CancellationToken);
+ cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Single(result.Content);
@@ -115,11 +141,11 @@ public async Task CallToolAsync_ImmediateResult_ReturnsDirectly()
}
[Fact]
- public async Task CallToolRawAsync_ImmediateResult_ReturnsResultNotTask()
+ public async Task CallToolAsTaskAsync_ImmediateResult_ReturnsResultNotTask()
{
await using var client = await CreateMcpClientForServer();
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "immediate-tool" },
TestContext.Current.CancellationToken);
@@ -130,11 +156,11 @@ public async Task CallToolRawAsync_ImmediateResult_ReturnsResultNotTask()
}
[Fact]
- public async Task CallToolRawAsync_AsyncTool_ReturnsTaskCreated()
+ public async Task CallToolAsTaskAsync_AsyncTool_ReturnsTaskCreated()
{
await using var client = await CreateMcpClientForServer();
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "async-tool" },
TestContext.Current.CancellationToken);
@@ -154,7 +180,7 @@ public async Task CallToolAsync_AsyncTool_PollsUntilCompleted()
// Complete the task after a brief delay so polling finds it.
_ = Task.Run(async () =>
{
- await Task.Delay(100, ct);
+ await Task.Delay(100, cancellationToken: ct);
// The store should have exactly one task by now
var taskId = _taskStore.GetAllTaskIds().Single();
_taskStore.CompleteTask(taskId, new CallToolResult
@@ -163,9 +189,8 @@ public async Task CallToolAsync_AsyncTool_PollsUntilCompleted()
});
}, ct);
- var result = await client.CallToolAsync(
- new CallToolRequestParams { Name = "async-tool" },
- ct);
+ var result = await client.CallToolWithPollingAsync(
+ new CallToolRequestParams { Name = "async-tool" }, cancellationToken: ct);
Assert.NotNull(result);
Assert.Single(result.Content);
@@ -192,9 +217,8 @@ public async Task CallToolAsync_AsyncTool_FailedTask_ThrowsMcpException()
};
await Assert.ThrowsAsync(async () =>
- await client.CallToolAsync(
- new CallToolRequestParams { Name = "async-tool" },
- ct));
+ await client.CallToolWithPollingAsync(
+ new CallToolRequestParams { Name = "async-tool" }, cancellationToken: ct));
Assert.True(await failedTask.Task);
}
@@ -219,9 +243,8 @@ public async Task CallToolAsync_AsyncTool_CancelledTask_ThrowsOperationCancelled
};
await Assert.ThrowsAsync(async () =>
- await client.CallToolAsync(
- new CallToolRequestParams { Name = "async-tool" },
- ct));
+ await client.CallToolWithPollingAsync(
+ new CallToolRequestParams { Name = "async-tool" }, cancellationToken: ct));
Assert.True(await cancelledTask.Task);
}
@@ -231,10 +254,10 @@ public async Task GetTaskAsync_ReturnsCurrentState()
{
await using var client = await CreateMcpClientForServer();
- // Create a task via CallToolRawAsync
- var augmented = await client.CallToolRawAsync(
+ // Create a task via CallToolAsTaskAsync
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "async-tool" },
- TestContext.Current.CancellationToken);
+ cancellationToken: TestContext.Current.CancellationToken);
var taskId = augmented.TaskCreated!.TaskId;
@@ -250,7 +273,7 @@ public async Task CancelTaskAsync_CancelsTask()
{
await using var client = await CreateMcpClientForServer();
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "async-tool" },
TestContext.Current.CancellationToken);
@@ -276,7 +299,7 @@ public async Task ConfigureTasks_AdvertisesExtensionInCapabilities()
var extensions = client.ServerCapabilities.Extensions;
#pragma warning restore MCP_EXTENSIONS
Assert.NotNull(extensions);
- Assert.True(extensions.ContainsKey(McpExtensions.Tasks));
+ Assert.True(extensions.ContainsKey(TasksProtocol.ExtensionId));
}
[Fact]
@@ -284,7 +307,7 @@ public async Task CreateTaskResult_HasResultTypeTask()
{
await using var client = await CreateMcpClientForServer();
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "async-tool" },
TestContext.Current.CancellationToken);
@@ -298,7 +321,7 @@ public async Task GetTaskAsync_ImmediatelyAfterCreate_Resolves()
// Strong consistency: tasks/get immediately after CreateTaskResult must resolve.
await using var client = await CreateMcpClientForServer();
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "async-tool" },
TestContext.Current.CancellationToken);
@@ -328,7 +351,7 @@ public async Task CancelTask_AlreadyTerminal_AcknowledgesIdempotently()
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "async-tool" }, ct);
var taskId = augmented.TaskCreated!.TaskId;
@@ -347,7 +370,7 @@ public async Task UpdateTaskAsync_TransitionsFromInputRequired()
var ct = TestContext.Current.CancellationToken;
// Create an input-required task
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "input-required-tool" }, ct);
var taskId = augmented.TaskCreated!.TaskId;
@@ -373,15 +396,15 @@ await client.UpdateTaskAsync(new UpdateTaskRequestParams
}
[Fact]
- public async Task CallToolRawAsync_InjectsTaskCapabilityInMeta()
+ public async Task CallToolAsTaskAsync_InjectsTaskCapabilityInMeta()
{
// Verify the server receives the task extension in _meta by intercepting
- // the handler. The CallToolWithTaskHandler already receives the request,
+ // the handler. The CallToolWithAlternateHandler already receives the request,
// so we can observe the meta there. We test the client-side injection indirectly
// by confirming the server returns a task result (which requires the capability signal).
await using var client = await CreateMcpClientForServer();
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "async-tool" },
TestContext.Current.CancellationToken);
@@ -390,14 +413,14 @@ public async Task CallToolRawAsync_InjectsTaskCapabilityInMeta()
}
[Fact]
- public async Task CallToolRawAsync_OptIn_UsesSep2575CapabilitiesEnvelope()
+ public async Task CallToolAsTaskAsync_OptIn_UsesSep2575CapabilitiesEnvelope()
{
// SEP-2663 §51: the per-request opt-in is the SEP-2575 capabilities envelope:
// _meta/io.modelcontextprotocol/clientCapabilities/extensions/io.modelcontextprotocol/tasks = {}
// This test pins the literal wire path so future refactors can't regress.
await using var client = await CreateMcpClientForServer();
- await client.CallToolRawAsync(
+ await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "immediate-tool" },
TestContext.Current.CancellationToken);
@@ -413,7 +436,7 @@ await client.CallToolRawAsync(
}
[Fact]
- public async Task CallToolRawAsync_OptIn_PreservesExistingMetaSiblings()
+ public async Task CallToolAsTaskAsync_OptIn_PreservesExistingMetaSiblings()
{
// User-supplied _meta entries at the root must not be clobbered, and the SEP-2575
// envelope must be added alongside them, not in place of them.
@@ -431,7 +454,7 @@ public async Task CallToolRawAsync_OptIn_PreservesExistingMetaSiblings()
},
};
- await client.CallToolRawAsync(
+ await client.CallToolAsTaskAsync(
new CallToolRequestParams
{
Name = "immediate-tool",
@@ -452,13 +475,13 @@ await client.CallToolRawAsync(
}
[Fact]
- public async Task CallToolRawAsync_PreservesExistingUserMeta()
+ public async Task CallToolAsTaskAsync_PreservesExistingUserMeta()
{
// Verify that user-supplied meta fields are not clobbered
await using var client = await CreateMcpClientForServer();
var userMeta = new JsonObject { ["customKey"] = "customValue" };
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams
{
Name = "immediate-tool",
@@ -494,8 +517,8 @@ public async Task CallToolAsync_RespectsServerPollInterval()
});
}, ct);
- var result = await client.CallToolAsync(
- new CallToolRequestParams { Name = "async-tool" }, ct);
+ var result = await client.CallToolWithPollingAsync(
+ new CallToolRequestParams { Name = "async-tool" }, cancellationToken: ct);
var elapsed = DateTime.UtcNow - startTime;
@@ -506,25 +529,25 @@ public async Task CallToolAsync_RespectsServerPollInterval()
}
[Fact]
- public async Task CallToolWithTaskHandler_ImplicitConversion_ReturnCallToolResult()
+ public async Task CallToolWithAlternateHandler_ImplicitConversion_ReturnCallToolResult()
{
- // Verify that the implicit conversion from CallToolResult to ResultOrCreatedTask works
+ // Verify that the implicit conversion from CallToolResult to ResultOrAlternate works
// in the handler context — this is already tested by "immediate-tool" working correctly.
await using var client = await CreateMcpClientForServer();
- var result = await client.CallToolAsync(
+ var result = await client.CallToolWithPollingAsync(
new CallToolRequestParams { Name = "immediate-tool" },
- TestContext.Current.CancellationToken);
+ cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal("immediate result", Assert.IsType(result.Content[0]).Text);
}
[Fact]
- public async Task CallToolHandler_And_CallToolWithTaskHandler_AreMutuallyExclusive()
+ public async Task CallToolHandler_And_CallToolWithAlternateHandler_AreMutuallyExclusive()
{
var handlers = new McpServerHandlers();
- handlers.CallToolWithTaskHandler = async (ctx, ct) => new CallToolResult();
+ handlers.CallToolWithAlternateHandler = async (ctx, ct) => new CallToolResult();
Assert.Throws(() =>
handlers.CallToolHandler = async (ctx, ct) => new CallToolResult());
@@ -532,7 +555,7 @@ public async Task CallToolHandler_And_CallToolWithTaskHandler_AreMutuallyExclusi
handlers.CallToolHandler = async (ctx, ct) => new CallToolResult();
Assert.Throws(() =>
- handlers.CallToolWithTaskHandler = async (ctx, ct) => new CallToolResult());
+ handlers.CallToolWithAlternateHandler = async (ctx, ct) => new CallToolResult());
}
[Fact]
@@ -544,8 +567,8 @@ public async Task CallToolHandler_CanBeSetToNull_ThenOtherCanBeSet()
handlers.CallToolHandler = null;
// Now setting the other should work
- handlers.CallToolWithTaskHandler = async (ctx, ct) => new CallToolResult();
- Assert.NotNull(handlers.CallToolWithTaskHandler);
+ handlers.CallToolWithAlternateHandler = async (ctx, ct) => new CallToolResult();
+ Assert.NotNull(handlers.CallToolWithAlternateHandler);
}
///
diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs
index 9e264af78..db051d19f 100644
--- a/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs
@@ -1,3 +1,4 @@
+using ModelContextProtocol.Extensions.Tasks;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
@@ -10,7 +11,7 @@ namespace ModelContextProtocol.Tests.Server;
///
/// Pins the behavior when a client signals the SEP-2575 tasks opt-in via _meta but the server
-/// has neither an nor a
+/// has neither nor a
/// configured. The expected behavior is a silent synchronous fallback: the server returns the normal
/// with no Task envelope and no exception.
///
@@ -25,7 +26,7 @@ public McpServerTasksNoStoreTests(ITestOutputHelper testOutputHelper) : base(tes
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
{
- // Intentionally do NOT configure TaskStore or CallToolWithTaskHandler.
+ // Intentionally do NOT configure TaskStore or CallToolWithAlternateHandler.
mcpServerBuilder.WithTools();
}
@@ -35,8 +36,8 @@ public async Task ClientOptIn_NoTaskStore_FallsBackToSyncResult()
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
- // CallToolRawAsync always writes the SEP-2575 tasks opt-in into _meta.
- var augmented = await client.CallToolRawAsync(
+ // CallToolAsTaskAsync always writes the SEP-2575 tasks opt-in into _meta.
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "sync-tool" }, ct);
// With no task store configured, the server must complete synchronously and return
@@ -54,8 +55,8 @@ public async Task ClientOptIn_NoTaskStore_CallToolAsync_StillReturnsResult()
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
- var result = await client.CallToolAsync(
- new CallToolRequestParams { Name = "sync-tool" }, ct);
+ var result = await client.CallToolWithPollingAsync(
+ new CallToolRequestParams { Name = "sync-tool" }, cancellationToken: ct);
Assert.NotNull(result);
Assert.Equal("sync result", Assert.IsType(result.Content[0]).Text);
diff --git a/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs
index 09a501ab8..a904c87ed 100644
--- a/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs
@@ -1,3 +1,4 @@
+using ModelContextProtocol.Extensions.Tasks;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
@@ -13,7 +14,7 @@ namespace ModelContextProtocol.Tests.Server;
///
/// Tests for the -based auto-wiring of tools/call into tasks.
-/// Verifies that setting enables task support
+/// Verifies that enables task support
/// for -based tools.
///
public class McpTaskStoreTests : ClientServerTestBase
@@ -27,23 +28,20 @@ public McpTaskStoreTests(ITestOutputHelper testOutputHelper) : base(testOutputHe
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
{
- mcpServerBuilder.WithTools();
-
- mcpServerBuilder.Services.Configure(options =>
- {
- options.TaskStore = new InMemoryMcpTaskStore
+ mcpServerBuilder
+ .WithTools()
+ .WithTasks(new InMemoryMcpTaskStore
{
DefaultPollIntervalMs = 50,
- };
- });
+ });
}
[Fact]
- public async Task CallToolRawAsync_WithTaskCapability_ReturnsCreateTaskResult()
+ public async Task CallToolAsTaskAsync_WithTaskCapability_ReturnsCreateTaskResult()
{
await using var client = await CreateMcpClientForServer();
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "slow-tool" },
TestContext.Current.CancellationToken);
@@ -60,9 +58,9 @@ public async Task CallToolAsync_WithTaskStore_PollsToCompletion()
await using var client = await CreateMcpClientForServer();
// CallToolAsync should poll until the background execution completes.
- var result = await client.CallToolAsync(
+ var result = await client.CallToolWithPollingAsync(
new CallToolRequestParams { Name = "slow-tool" },
- TestContext.Current.CancellationToken);
+ cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.Single(result.Content);
@@ -75,7 +73,7 @@ public async Task CallToolAsync_WithTaskStore_FastTool_StillCreatesTask()
await using var client = await CreateMcpClientForServer();
// Even a fast tool should go through the task store when the client signals capability.
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "fast-tool" },
TestContext.Current.CancellationToken);
@@ -88,8 +86,8 @@ public async Task GetTaskAsync_ViaStore_ReturnsCompletedResult()
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
- var augmented = await client.CallToolRawAsync(
- new CallToolRequestParams { Name = "fast-tool" }, ct);
+ var augmented = await client.CallToolAsTaskAsync(
+ new CallToolRequestParams { Name = "fast-tool" }, cancellationToken: ct);
var taskId = augmented.TaskCreated!.TaskId;
@@ -115,7 +113,7 @@ public async Task CancelTaskAsync_ViaStore_TransitionsToCancelled()
var ct = TestContext.Current.CancellationToken;
// Create a slow task that won't complete on its own
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "slow-tool" }, ct);
var taskId = augmented.TaskCreated!.TaskId;
@@ -145,7 +143,7 @@ public async Task ToolExecution_Failure_StoresAsCompletedWithError()
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "failing-tool" }, ct);
var taskId = augmented.TaskCreated!.TaskId;
@@ -176,7 +174,7 @@ public async Task McpProtocolException_FromTool_StoresAsFailedWithJsonRpcErrorSh
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "throws-mcp-protocol" }, ct);
var taskId = augmented.TaskCreated!.TaskId;
@@ -210,7 +208,7 @@ public async Task InputRequiredException_FromTool_FailsTaskWithActionableMessage
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "mrtr-tool" }, ct);
var taskId = augmented.TaskCreated!.TaskId;
@@ -233,7 +231,7 @@ public async Task InputRequiredException_FromTool_FailsTaskWithActionableMessage
var message = failed.Error.GetProperty("message").GetString();
Assert.NotNull(message);
Assert.Contains("MRTR", message);
- Assert.Contains(nameof(McpServerHandlers.CallToolWithTaskHandler), message);
+ Assert.Contains("tasks", message);
}
[Fact]
@@ -253,8 +251,8 @@ public async Task ElicitTool_ViaTask_RedirectsThroughStore()
var ct = TestContext.Current.CancellationToken;
// CallToolAsync will poll and resolve input requests automatically.
- var result = await client.CallToolAsync(
- new CallToolRequestParams { Name = "elicit-tool" }, ct);
+ var result = await client.CallToolWithPollingAsync(
+ new CallToolRequestParams { Name = "elicit-tool" }, cancellationToken: ct);
Assert.NotNull(result);
Assert.Equal("accepted", Assert.IsType(result.Content[0]).Text);
@@ -279,8 +277,8 @@ public async Task SampleTool_ViaTask_RedirectsThroughStore()
});
var ct = TestContext.Current.CancellationToken;
- var result = await client.CallToolAsync(
- new CallToolRequestParams { Name = "sample-tool" }, ct);
+ var result = await client.CallToolWithPollingAsync(
+ new CallToolRequestParams { Name = "sample-tool" }, cancellationToken: ct);
Assert.NotNull(result);
Assert.Equal("sampled response", Assert.IsType(result.Content[0]).Text);
@@ -309,8 +307,8 @@ public async Task RootsTool_ViaTask_RedirectsThroughStore()
});
var ct = TestContext.Current.CancellationToken;
- var result = await client.CallToolAsync(
- new CallToolRequestParams { Name = "roots-tool" }, ct);
+ var result = await client.CallToolWithPollingAsync(
+ new CallToolRequestParams { Name = "roots-tool" }, cancellationToken: ct);
Assert.NotNull(result);
Assert.Equal("file:///workspace,file:///other", Assert.IsType(result.Content[0]).Text);
@@ -328,12 +326,10 @@ public async Task SendTaskStatusNotificationAsync_FromTool_DeliversTypedNotifica
var ct = TestContext.Current.CancellationToken;
await using var registration = client.RegisterNotificationHandler(
- NotificationMethods.TaskStatusNotification,
+ TasksProtocol.NotificationTaskStatus,
(notification, _) =>
{
- var typed = JsonSerializer.Deserialize(
- notification.Params,
- McpJsonUtilities.DefaultOptions);
+ var typed = JsonSerializer.Deserialize(notification.Params, McpTasksJsonContext.Default.Options);
if (typed is not null)
{
notifications.Writer.TryWrite(typed);
@@ -342,8 +338,8 @@ public async Task SendTaskStatusNotificationAsync_FromTool_DeliversTypedNotifica
return default;
});
- var result = await client.CallToolAsync(
- new CallToolRequestParams { Name = "notifying-tool" }, ct);
+ var result = await client.CallToolWithPollingAsync(
+ new CallToolRequestParams { Name = "notifying-tool" }, cancellationToken: ct);
Assert.Equal("notified", Assert.IsType(result.Content[0]).Text);
@@ -377,12 +373,10 @@ public async Task SendTaskStatusNotificationAsync_Failed_DeliversTypedNotificati
var ct = TestContext.Current.CancellationToken;
await using var registration = client.RegisterNotificationHandler(
- NotificationMethods.TaskStatusNotification,
+ TasksProtocol.NotificationTaskStatus,
(notification, _) =>
{
- var typed = JsonSerializer.Deserialize(
- notification.Params,
- McpJsonUtilities.DefaultOptions);
+ var typed = JsonSerializer.Deserialize(notification.Params, McpTasksJsonContext.Default.Options);
if (typed is FailedTaskNotificationParams)
{
notifications.Writer.TryWrite(typed);
@@ -393,8 +387,8 @@ public async Task SendTaskStatusNotificationAsync_Failed_DeliversTypedNotificati
// The tool emits a Failed notification then returns a normal result, so we isolate the
// notification round-trip from the task-store's own failure handling.
- var result = await client.CallToolAsync(
- new CallToolRequestParams { Name = "failing-notify-tool" }, ct);
+ var result = await client.CallToolWithPollingAsync(
+ new CallToolRequestParams { Name = "failing-notify-tool" }, cancellationToken: ct);
Assert.Equal("emitted-failed", Assert.IsType(result.Content[0]).Text);
@@ -426,8 +420,8 @@ public async Task ElicitTool_ViaTask_ClientDedups_InputRequests()
});
var ct = TestContext.Current.CancellationToken;
- var result = await client.CallToolAsync(
- new CallToolRequestParams { Name = "elicit-tool" }, ct);
+ var result = await client.CallToolWithPollingAsync(
+ new CallToolRequestParams { Name = "elicit-tool" }, cancellationToken: ct);
// The handler should be called exactly once despite potential multiple polls
Assert.Equal(1, elicitCallCount);
@@ -435,7 +429,7 @@ public async Task ElicitTool_ViaTask_ClientDedups_InputRequests()
}
[Fact]
- public async Task CallToolRawAsync_ElicitTool_ReturnsTask_ThenPollShowsInputRequired()
+ public async Task CallToolAsTaskAsync_ElicitTool_ReturnsTask_ThenPollShowsInputRequired()
{
await using var client = await CreateMcpClientForServer(new McpClientOptions
{
@@ -447,7 +441,7 @@ public async Task CallToolRawAsync_ElicitTool_ReturnsTask_ThenPollShowsInputRequ
});
var ct = TestContext.Current.CancellationToken;
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "elicit-tool" }, ct);
Assert.True(augmented.IsTask);
@@ -476,7 +470,7 @@ public async Task CancelTaskAsync_AlreadyCompleted_AcknowledgesIdempotently_AndD
var ct = TestContext.Current.CancellationToken;
// Run a fast tool to completion via the task store.
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "fast-tool" }, ct);
var taskId = augmented.TaskCreated!.TaskId;
@@ -516,7 +510,7 @@ public async Task CallToolAsync_ElicitHandlerThrows_PropagatesAndDoesNotLeaveCli
var sw = System.Diagnostics.Stopwatch.StartNew();
var ex = await Assert.ThrowsAsync(async () =>
- await client.CallToolAsync(new CallToolRequestParams { Name = "elicit-tool" }, ct));
+ await client.CallToolWithPollingAsync(new CallToolRequestParams { Name = "elicit-tool" }, cancellationToken: ct));
sw.Stop();
Assert.Equal("handler-failed", ex.Message);
@@ -531,7 +525,7 @@ public async Task CallToolAsync_ElicitHandlerThrows_PropagatesAndDoesNotLeaveCli
public async Task CallTool_WithoutTaskExtensionMeta_ReturnsCallToolResultImmediately()
{
// SEP-2663: "A server MUST NOT return a CreateTaskResult to a client that did not include the
- // extension capability." We bypass CallToolRawAsync (which injects the marker) and send a raw
+ // extension capability." We bypass CallToolAsTaskAsync (which injects the marker) and send a raw
// tools/call request without the io.modelcontextprotocol/tasks key in _meta.
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
@@ -561,7 +555,7 @@ public async Task ToolReturnsCallToolResultWithIsError_AsTask_StoresAsCompleted_
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "iserror-tool" }, ct);
var taskId = augmented.TaskCreated!.TaskId;
@@ -602,8 +596,8 @@ public async Task MultiElicit_ViaTask_HandlerCalledExactlyOncePerUniqueKey_Acros
});
var ct = TestContext.Current.CancellationToken;
- var result = await client.CallToolAsync(
- new CallToolRequestParams { Name = "multi-elicit-tool" }, ct);
+ var result = await client.CallToolWithPollingAsync(
+ new CallToolRequestParams { Name = "multi-elicit-tool" }, cancellationToken: ct);
// Exactly two handler invocations — one per unique input request key.
Assert.Equal(2, elicitCount);
diff --git a/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs
index 0e8693353..e751bdcdc 100644
--- a/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs
@@ -1,3 +1,4 @@
+using ModelContextProtocol.Extensions.Tasks;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
@@ -29,16 +30,13 @@ public TaskCancellationIntegrationTests(ITestOutputHelper testOutputHelper)
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
{
- mcpServerBuilder.Services.Configure(options =>
- {
- options.TaskStore = new InMemoryMcpTaskStore
+ mcpServerBuilder
+ .WithTasks(new InMemoryMcpTaskStore
{
DefaultPollIntervalMs = 50,
DefaultTimeToLive = TimeSpan.FromSeconds(5),
- };
- });
-
- mcpServerBuilder.WithTools([McpServerTool.Create(
+ })
+ .WithTools([McpServerTool.Create(
async (CancellationToken ct) =>
{
_toolStarted.TrySetResult(true);
@@ -66,7 +64,7 @@ public async Task TaskTool_CancellationToken_FiresWhenExplicitlyCancelled()
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "long-running-tool" }, ct);
Assert.True(augmented.IsTask);
@@ -93,7 +91,7 @@ public async Task TaskTool_CancellationToken_GetTaskShowsWorkingBeforeCancel()
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "long-running-tool" }, ct);
Assert.True(augmented.IsTask);
@@ -130,15 +128,12 @@ public TaskCancellationConcurrencyTests(ITestOutputHelper testOutputHelper)
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
{
- mcpServerBuilder.Services.Configure(options =>
- {
- options.TaskStore = new InMemoryMcpTaskStore
+ mcpServerBuilder
+ .WithTasks(new InMemoryMcpTaskStore
{
DefaultPollIntervalMs = 50,
- };
- });
-
- mcpServerBuilder.WithTools([McpServerTool.Create(
+ })
+ .WithTools([McpServerTool.Create(
async (string marker, CancellationToken ct) =>
{
TaskCompletionSource startTcs;
@@ -219,14 +214,14 @@ public async Task CancelTask_OnlyCancelsTargetTask_NotOtherTasks()
RegisterMarker("task2");
// Start two tasks
- var result1 = await client.CallToolRawAsync(
+ var result1 = await client.CallToolAsTaskAsync(
new CallToolRequestParams
{
Name = "trackable-tool",
Arguments = CreateMarkerArgs("task1"),
}, ct);
- var result2 = await client.CallToolRawAsync(
+ var result2 = await client.CallToolAsTaskAsync(
new CallToolRequestParams
{
Name = "trackable-tool",
@@ -273,15 +268,12 @@ public TerminalTaskStatusTransitionTests(ITestOutputHelper testOutputHelper)
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
{
- mcpServerBuilder.Services.Configure(options =>
- {
- options.TaskStore = new InMemoryMcpTaskStore
+ mcpServerBuilder
+ .WithTasks(new InMemoryMcpTaskStore
{
DefaultPollIntervalMs = 50,
- };
- });
-
- mcpServerBuilder.WithTools([
+ })
+ .WithTools([
McpServerTool.Create(
async (CancellationToken ct) =>
{
@@ -316,7 +308,7 @@ public async Task CompletedTask_CancelIsAcknowledgedIdempotentlyAndStateUnchange
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "quick-tool" }, ct);
Assert.True(augmented.IsTask);
@@ -346,7 +338,7 @@ public async Task CompletedWithErrorTask_CancelIsAcknowledgedIdempotently()
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "failing-tool" }, ct);
Assert.True(augmented.IsTask);
diff --git a/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs
index 8cd2f7839..84f32267a 100644
--- a/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs
@@ -1,17 +1,23 @@
+using ModelContextProtocol.Extensions.Tasks;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Runtime.InteropServices;
+using System.Text.Json;
+using System.Text.Json.Serialization.Metadata;
namespace ModelContextProtocol.Tests.Server;
///
-/// Verifies the runtime validation that fires when a handler opts into task-augmented execution
-/// (returns a ) without the server having any tasks/get
-/// handler registered.
+/// Verifies behavior when a handler returns a alternate without
+/// the server having any tasks/get handler registered. After the ResultOrAlternate
+/// generalization, the Core server no longer guards against this -- the extension is responsible
+/// for ensuring lifecycle handlers are registered.
///
public class TaskHandlerConfigurationValidationTests : ClientServerTestBase
{
+ private static readonly JsonTypeInfo s_createTaskResultTypeInfo = McpTasksJsonContext.Default.CreateTaskResult;
+
public TaskHandlerConfigurationValidationTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper)
{
#if !NET
@@ -25,35 +31,32 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer
{
options.Capabilities ??= new ServerCapabilities();
- // Intentionally configure a task-augmented handler without TaskStore or any of the
- // task lifecycle handlers (GetTaskHandler/UpdateTaskHandler/CancelTaskHandler).
- options.Handlers.CallToolWithTaskHandler = (context, cancellationToken) =>
- new ValueTask>(new CreateTaskResult
- {
- TaskId = "orphan-task",
- Status = McpTaskStatus.Working,
- CreatedAt = DateTimeOffset.UtcNow,
- LastUpdatedAt = DateTimeOffset.UtcNow,
- });
+ // Configure a task-augmented handler without TaskStore or any of the
+ // task lifecycle request handlers.
+ options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) =>
+ new ValueTask>(
+ new ResultOrAlternate(
+ new CreateTaskResult
+ {
+ TaskId = "orphan-task",
+ Status = McpTaskStatus.Working,
+ CreatedAt = DateTimeOffset.UtcNow,
+ LastUpdatedAt = DateTimeOffset.UtcNow,
+ },
+ s_createTaskResultTypeInfo));
});
}
[Fact]
- public async Task CallTool_ReturningCreateTaskResult_WithoutTasksGetHandler_ThrowsAtRequestTime()
+ public async Task ServerAcceptsAlternateHandler_WithoutTasksGetHandler_NoStartupError()
{
+ // The Core guard that previously threw InvalidOperationException at request time when
+ // a CallToolWithAlternateHandler returned a CreateTaskResult without tasks/get being
+ // registered has been removed. The extension is now responsible for that guarantee.
+ // This test verifies the server starts and connects successfully with such configuration.
await using var client = await CreateMcpClientForServer();
- // Client surfaces a generic protocol error (the server intentionally redacts the message
- // on the wire), so use the base McpException type and confirm via server-side logs that
- // the originating exception was the misconfiguration guard.
- await Assert.ThrowsAnyAsync(async () =>
- await client.CallToolAsync(
- new CallToolRequestParams { Name = "anything" },
- cancellationToken: TestContext.Current.CancellationToken));
-
- Assert.Contains(MockLoggerProvider.LogMessages, log =>
- log.Exception is InvalidOperationException ioe &&
- ioe.Message.Contains("tasks/get", StringComparison.Ordinal) &&
- ioe.Message.Contains("CreateTaskResult", StringComparison.Ordinal));
+ // If we get here, the server accepted the handler config without error.
+ Assert.NotNull(client);
}
}
diff --git a/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs
index 1d17be223..58a0e68ec 100644
--- a/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs
@@ -1,10 +1,13 @@
+using ModelContextProtocol.Extensions.Tasks;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Runtime.InteropServices;
+using System.Text.Json;
+using System.Text.Json.Nodes;
-#pragma warning disable MCPEXP001
+#pragma warning disable MCPEXP001, MCPEXP002
namespace ModelContextProtocol.Tests.Server;
@@ -29,49 +32,66 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer
mcpServerBuilder.Services.Configure(options =>
{
options.Capabilities ??= new ServerCapabilities();
+ options.Capabilities.Extensions ??= new Dictionary();
+ options.Capabilities.Extensions[TasksProtocol.ExtensionId] = new JsonObject();
+ options.RequestHandlers ??= new List();
// CallTool always returns a CreateTaskResult with a tiny poll interval so the
// test exercises the threshold in well under a second.
- options.Handlers.CallToolWithTaskHandler = (context, cancellationToken) =>
+ options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) =>
{
var taskId = Guid.NewGuid().ToString("N");
- return new ValueTask>(new CreateTaskResult
- {
- TaskId = taskId,
- Status = McpTaskStatus.InputRequired,
- CreatedAt = DateTimeOffset.UtcNow,
- LastUpdatedAt = DateTimeOffset.UtcNow,
- PollIntervalMs = 5,
- ResultType = "task",
- });
+ return new ValueTask>(
+ new ResultOrAlternate(
+ new CreateTaskResult
+ {
+ TaskId = taskId,
+ Status = McpTaskStatus.InputRequired,
+ CreatedAt = DateTimeOffset.UtcNow,
+ LastUpdatedAt = DateTimeOffset.UtcNow,
+ PollIntervalMs = 5,
+ ResultType = "task",
+ },
+ McpTasksJsonContext.Default.CreateTaskResult));
};
// GetTask always reports InputRequired with NO outstanding input requests — the
// misbehaving-server condition the stuck-detector exists to break out of.
- options.Handlers.GetTaskHandler = (context, cancellationToken) =>
+ options.RequestHandlers.Add(new McpServerRequestHandler
{
- Interlocked.Increment(ref _pollCount);
-
- return new ValueTask(new InputRequiredTaskResult
+ Method = TasksProtocol.MethodTasksGet,
+ Handler = (request, cancellationToken) =>
{
- TaskId = context.Params!.TaskId,
- CreatedAt = DateTimeOffset.UtcNow,
- LastUpdatedAt = DateTimeOffset.UtcNow,
- PollIntervalMs = 5,
- InputRequests = new Dictionary(),
- ResultType = "complete",
- });
- };
-
- // CancelTask must succeed since the client issues a best-effort cancel when it
- // gives up; otherwise the cancel failure would mask the real exception.
- options.Handlers.CancelTaskHandler = (context, cancellationToken) =>
- new ValueTask(new CancelTaskResult { ResultType = "complete" });
+ var requestParams = JsonSerializer.Deserialize(request.Params, McpTasksJsonContext.Default.Options)
+ ?? throw new McpProtocolException("Missing params for tasks/get", McpErrorCode.InvalidParams);
+
+ Interlocked.Increment(ref _pollCount);
+
+ return new ValueTask(JsonSerializer.SerializeToNode(new InputRequiredTaskResult
+ {
+ TaskId = requestParams.TaskId,
+ CreatedAt = DateTimeOffset.UtcNow,
+ LastUpdatedAt = DateTimeOffset.UtcNow,
+ PollIntervalMs = 5,
+ InputRequests = new Dictionary(),
+ ResultType = "complete",
+ }, McpTasksJsonContext.Default.Options));
+ },
+ });
+
+ options.RequestHandlers.Add(new McpServerRequestHandler
+ {
+ Method = TasksProtocol.MethodTasksCancel,
+ Handler = (request, cancellationToken) =>
+ new ValueTask(JsonSerializer.SerializeToNode(new CancelTaskResult { ResultType = "complete" }, McpTasksJsonContext.Default.Options)),
+ });
- // UpdateTask is never invoked in this scenario (there are no input requests to resolve)
- // but must be present so the handler-configuration validation passes.
- options.Handlers.UpdateTaskHandler = (context, cancellationToken) =>
- new ValueTask(new UpdateTaskResult { ResultType = "complete" });
+ options.RequestHandlers.Add(new McpServerRequestHandler
+ {
+ Method = TasksProtocol.MethodTasksUpdate,
+ Handler = (request, cancellationToken) =>
+ new ValueTask(JsonSerializer.SerializeToNode(new UpdateTaskResult { ResultType = "complete" }, McpTasksJsonContext.Default.Options)),
+ });
});
}
@@ -82,7 +102,7 @@ public async Task CallToolAsync_TaskStuckInInputRequired_WithoutNewRequests_Thro
var ct = TestContext.Current.CancellationToken;
var ex = await Assert.ThrowsAsync(async () =>
- await client.CallToolAsync(new CallToolRequestParams { Name = "any-tool" }, ct));
+ await client.CallToolWithPollingAsync(new CallToolRequestParams { Name = "any-tool" }, cancellationToken: ct));
Assert.Contains(McpTaskStatus.InputRequired.ToString(), ex.Message);
Assert.Contains("consecutive polls", ex.Message);
@@ -93,18 +113,15 @@ public async Task CallToolAsync_TaskStuckInInputRequired_WithoutNewRequests_Thro
[Fact]
public async Task CallToolAsync_StuckDetector_HonorsConfiguredThreshold()
{
- // Verifies McpClientOptions.MaxConsecutiveStuckPolls is plumbed into PollTaskToCompletionAsync:
+ // Verifies CallToolWithPollingAsync plumbs the explicit threshold into PollTaskToCompletionAsync:
// a smaller configured threshold is surfaced verbatim in the McpException message.
const int CustomThreshold = 3;
- await using var client = await CreateMcpClientForServer(new McpClientOptions
- {
- MaxConsecutiveStuckPolls = CustomThreshold,
- });
+ await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
var ex = await Assert.ThrowsAsync(async () =>
- await client.CallToolAsync(new CallToolRequestParams { Name = "any-tool" }, ct));
+ await client.CallToolWithPollingAsync(new CallToolRequestParams { Name = "any-tool" }, maxConsecutiveStuckPolls: CustomThreshold, cancellationToken: ct));
// The message embeds the configured threshold, which is the strongest signal that the
// option value (not the 60-default constant) is what governed the loop.
@@ -112,19 +129,4 @@ public async Task CallToolAsync_StuckDetector_HonorsConfiguredThreshold()
Assert.Equal(CustomThreshold, _pollCount);
}
- [Theory]
- [InlineData(0)]
- [InlineData(-1)]
- [InlineData(int.MinValue)]
- public void McpClientOptions_MaxConsecutiveStuckPolls_RejectsNonPositive(int value)
- {
- var options = new McpClientOptions();
- Assert.Throws(() => options.MaxConsecutiveStuckPolls = value);
- }
-
- [Fact]
- public void McpClientOptions_MaxConsecutiveStuckPolls_DefaultsTo60()
- {
- Assert.Equal(60, new McpClientOptions().MaxConsecutiveStuckPolls);
- }
}
diff --git a/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs
index 7f9790526..5379dac8c 100644
--- a/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs
@@ -1,3 +1,4 @@
+using ModelContextProtocol.Extensions.Tasks;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
@@ -6,8 +7,6 @@
using System.Text.Json;
using System.Text.Json.Nodes;
-#pragma warning disable MCPEXP001
-
namespace ModelContextProtocol.Tests.Server;
///
@@ -32,15 +31,12 @@ public TaskProtocolGatingTests(ITestOutputHelper outputHelper)
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
{
- mcpServerBuilder.Services.Configure(options =>
- {
- options.TaskStore = new InMemoryMcpTaskStore
+ mcpServerBuilder
+ .WithTasks(new InMemoryMcpTaskStore
{
DefaultPollIntervalMs = 50,
- };
- });
-
- mcpServerBuilder.WithTools([McpServerTool.Create(
+ })
+ .WithTools([McpServerTool.Create(
async (string input, CancellationToken ct) =>
{
await Task.Delay(50, ct);
@@ -68,7 +64,7 @@ private static JsonObject CreateForgedTaskOptInMeta() =>
{
[ExtensionsKey] = new JsonObject
{
- [McpExtensions.Tasks] = new JsonObject(),
+ [TasksProtocol.ExtensionId] = new JsonObject(),
},
},
};
@@ -115,7 +111,7 @@ public async Task LegacyClient_CallToolRaw_ReturnsDirectResult_NoTaskCreated()
await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion });
var ct = TestContext.Current.CancellationToken;
- var result = await client.CallToolRawAsync(
+ var result = await client.CallToolAsTaskAsync(
new CallToolRequestParams
{
Name = "test-tool",
@@ -135,7 +131,7 @@ public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_ServerReturnsDire
// Forge a SEP-2575 capabilities envelope carrying the tasks extension opt-in on a legacy
// request. The server must still refuse to create a task because the per-request protocol
// version is not the 2026-07-28 protocol.
- var result = await client.CallToolRawAsync(
+ var result = await client.CallToolAsTaskAsync(
new CallToolRequestParams
{
Name = "test-tool",
@@ -157,10 +153,9 @@ public async Task LegacyClient_RawTasksGetRequest_ReturnsMethodNotFound()
// gates tasks/* to the 2026-07-28 protocol and must reject this legacy request with MethodNotFound.
var request = new JsonRpcRequest
{
- Method = RequestMethods.TasksGet,
+ Method = TasksProtocol.MethodTasksGet,
Params = JsonSerializer.SerializeToNode(
- new GetTaskRequestParams { TaskId = "some-task-id" },
- McpJsonUtilities.DefaultOptions),
+ new GetTaskRequestParams { TaskId = "some-task-id" }, McpTasksJsonContext.Default.Options),
};
var ex = await Assert.ThrowsAsync(async () =>
@@ -176,7 +171,7 @@ public async Task July2026ProtocolClient_CallToolRaw_CreatesTask()
await using var client = await CreateMcpClientForServer();
var ct = TestContext.Current.CancellationToken;
- var result = await client.CallToolRawAsync(
+ var result = await client.CallToolAsTaskAsync(
new CallToolRequestParams
{
Name = "test-tool",
diff --git a/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs
index 303d16e17..97221b2f9 100644
--- a/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs
@@ -1,22 +1,26 @@
+using ModelContextProtocol.Extensions.Tasks;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Runtime.InteropServices;
using System.Text.Json;
+using System.Text.Json.Serialization.Metadata;
#pragma warning disable MCPEXP001
namespace ModelContextProtocol.Tests.Server;
///
-/// Verifies that when both and
-/// are configured and the handler returns
+/// Verifies that when both and
+/// are configured and the handler returns
/// (IsTask = true), the store's pre-created task is failed with a
/// clear error rather than being orphaned in forever.
///
public class TaskStoreOrphanedTaskTests : ClientServerTestBase
{
+ private static readonly JsonTypeInfo s_createTaskResultTypeInfo = McpTasksJsonContext.Default.CreateTaskResult;
+
public TaskStoreOrphanedTaskTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper)
{
#if !NET
@@ -26,21 +30,25 @@ public TaskStoreOrphanedTaskTests(ITestOutputHelper testOutputHelper) : base(tes
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
{
+ mcpServerBuilder.WithTasks(new InMemoryMcpTaskStore());
+
mcpServerBuilder.Services.Configure(options =>
{
options.Capabilities ??= new ServerCapabilities();
- options.TaskStore = new InMemoryMcpTaskStore();
- // Returning IsTask = true here while TaskStore is also configured is the
+ // Returning IsTask = true here while the tasks extension is also configured is the
// misconfiguration the server must guard against.
- options.Handlers.CallToolWithTaskHandler = (context, cancellationToken) =>
- new ValueTask>(new CreateTaskResult
- {
- TaskId = "user-task",
- Status = McpTaskStatus.Working,
- CreatedAt = DateTimeOffset.UtcNow,
- LastUpdatedAt = DateTimeOffset.UtcNow,
- });
+ options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) =>
+ new ValueTask>(
+ new ResultOrAlternate(
+ new CreateTaskResult
+ {
+ TaskId = "user-task",
+ Status = McpTaskStatus.Working,
+ CreatedAt = DateTimeOffset.UtcNow,
+ LastUpdatedAt = DateTimeOffset.UtcNow,
+ },
+ s_createTaskResultTypeInfo));
});
}
@@ -51,7 +59,7 @@ public async Task TaskStoreAndHandler_BothCreatingTasks_FailsStoreTaskWithClearE
var ct = TestContext.Current.CancellationToken;
// The store's task is created synchronously and its taskId returned to the client.
- var augmented = await client.CallToolRawAsync(
+ var augmented = await client.CallToolAsTaskAsync(
new CallToolRequestParams { Name = "anything" }, ct);
Assert.True(augmented.IsTask);
@@ -76,7 +84,7 @@ public async Task TaskStoreAndHandler_BothCreatingTasks_FailsStoreTaskWithClearE
var message = failed.Error.GetProperty("message").GetString();
Assert.NotNull(message);
- Assert.Contains(nameof(McpServerOptions.TaskStore), message);
- Assert.Contains(nameof(McpServerHandlers.CallToolWithTaskHandler), message);
+ Assert.Contains(nameof(IMcpTaskStore), message);
+ Assert.Contains(nameof(McpServerHandlers.CallToolWithAlternateHandler), message);
}
}