Extract Tasks into the ModelContextProtocol.Extensions.Tasks extension package (#1693)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Tarek Mahmoud Sayed <tarekms@ntdev.microsoft.com>
This commit is contained in:
parent
ffc557f920
commit
2ca96f548b
11 changed files with 2559 additions and 1895 deletions
|
|
@ -1,154 +0,0 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ModelContextProtocol.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an interface for storing and managing the lifecycle of MCP tasks.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The task store manages the state of tasks created by the server's request handling pipeline.
|
||||
/// When a client signals support for the <c>io.modelcontextprotocol/tasks</c> extension on a request,
|
||||
/// the server creates a task in the store, executes the work in the background, and stores the result
|
||||
/// upon completion.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Implementations must be thread-safe. The store also provides the backing implementation for
|
||||
/// <c>tasks/get</c>, <c>tasks/update</c>, and <c>tasks/cancel</c> protocol methods.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Lifetime under stateless HTTP:</b> when the server is configured for stateless HTTP
|
||||
/// (each request creates a fresh server instance), the same <see cref="IMcpTaskStore"/> instance
|
||||
/// MUST be shared across requests — either by registering the store as a singleton in the DI
|
||||
/// container, or by backing it with external storage (database, distributed cache, etc.) that
|
||||
/// every server instance can reach. Otherwise <c>tasks/get</c> polls issued on subsequent
|
||||
/// requests will see an empty in-memory store and never find the task they are polling for.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// See the <see href="https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md">SEP-2663</see>
|
||||
/// specification for details on the tasks extension.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IMcpTaskStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new task for tracking an asynchronous execution.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>
|
||||
/// A <see cref="McpTaskInfo"/> with a unique task ID, initial status of <see cref="McpTaskStatus.Working"/>,
|
||||
/// and timing metadata (TTL, poll interval).
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Implementations must generate a unique task ID and set appropriate timestamps.
|
||||
/// The server infrastructure maps the returned <see cref="McpTaskInfo"/> to the appropriate
|
||||
/// protocol response type when communicating with clients.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Per the MCP specification (SEP-2663 §306), the returned task MUST be durably created
|
||||
/// before this method completes: a subsequent <see cref="GetTaskAsync"/> with the returned
|
||||
/// <see cref="McpTaskInfo.TaskId"/> MUST resolve, even if it runs on a different process or
|
||||
/// node. Implementations backed by eventually-consistent storage must therefore wait for the
|
||||
/// write to be visible (e.g., quorum acknowledgement, write-through, or an equivalent
|
||||
/// barrier) before returning.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
Task<McpTaskInfo> CreateTaskAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the current state of a task.
|
||||
/// </summary>
|
||||
/// <param name="taskId">The unique identifier of the task to retrieve.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>
|
||||
/// A <see cref="McpTaskInfo"/> representing the current task state,
|
||||
/// or <see langword="null"/> if the task does not exist.
|
||||
/// </returns>
|
||||
Task<McpTaskInfo?> GetTaskAsync(string taskId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Stores the result of a completed execution, transitioning the task to <see cref="McpTaskStatus.Completed"/>.
|
||||
/// </summary>
|
||||
/// <param name="taskId">The unique identifier of the task.</param>
|
||||
/// <param name="result">The serialized result payload.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
Task SetCompletedAsync(string taskId, JsonElement result, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Marks a task as failed, transitioning it to <see cref="McpTaskStatus.Failed"/>.
|
||||
/// </summary>
|
||||
/// <param name="taskId">The unique identifier of the task.</param>
|
||||
/// <param name="error">The serialized error information.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
Task SetFailedAsync(string taskId, JsonElement error, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Transitions the task to <see cref="McpTaskStatus.Cancelled"/>.
|
||||
/// </summary>
|
||||
/// <param name="taskId">The unique identifier of the task to cancel.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> if the task was successfully cancelled;
|
||||
/// <see langword="false"/> if the task does not exist or was already in a terminal state.
|
||||
/// </returns>
|
||||
Task<bool> SetCancelledAsync(string taskId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes input requests that have been satisfied by the provided responses and
|
||||
/// raises <see cref="InputResponseReceived"/> for each resolved entry.
|
||||
/// </summary>
|
||||
/// <param name="taskId">The unique identifier of the task.</param>
|
||||
/// <param name="inputResponses">
|
||||
/// The input responses keyed by the original request identifier.
|
||||
/// Matched input requests are removed from the task's pending set.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// After removing the satisfied requests, if no pending input requests remain the task
|
||||
/// transitions back to <see cref="McpTaskStatus.Working"/>. Otherwise it remains in
|
||||
/// <see cref="McpTaskStatus.InputRequired"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Implementations must raise <see cref="InputResponseReceived"/> for each entry in
|
||||
/// <paramref name="inputResponses"/> after updating the store state. In distributed
|
||||
/// deployments, this event enables the originating server to be notified even if a
|
||||
/// different server instance processes the <c>tasks/update</c> request.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
Task ResolveInputRequestsAsync(
|
||||
string taskId,
|
||||
IDictionary<string, InputResponse> inputResponses,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when an input response is resolved for a task.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Implementations must raise this event for each input response resolved in
|
||||
/// <see cref="ResolveInputRequestsAsync"/>. Subscribers use this to complete
|
||||
/// pending input request waiters (e.g., elicitation or sampling calls that are
|
||||
/// awaiting a client response).
|
||||
/// </remarks>
|
||||
event Action<InputResponseReceivedEventArgs>? InputResponseReceived;
|
||||
|
||||
/// <summary>
|
||||
/// Adds input requests to a task, transitioning it to <see cref="McpTaskStatus.InputRequired"/>.
|
||||
/// </summary>
|
||||
/// <param name="taskId">The unique identifier of the task.</param>
|
||||
/// <param name="inputRequests">
|
||||
/// The input requests to add. Keys are arbitrary identifiers for matching requests to responses.
|
||||
/// Each value is an <see cref="InputRequest"/> wrapping the server-to-client request payload.
|
||||
/// New requests are merged with any existing pending requests.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
Task SetInputRequestsAsync(
|
||||
string taskId,
|
||||
IDictionary<string, InputRequest> inputRequests,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ModelContextProtocol.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an in-memory implementation of <see cref="IMcpTaskStore"/> for development and testing scenarios.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This implementation stores all task state in memory using immutable snapshots and
|
||||
/// compare-and-swap updates for thread safety without locks.
|
||||
/// Tasks are not persisted across process restarts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// For production scenarios requiring durability, session isolation, or TTL-based cleanup,
|
||||
/// implement a custom <see cref="IMcpTaskStore"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class InMemoryMcpTaskStore : IMcpTaskStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, McpTaskInfo> _tasks = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default poll interval in milliseconds for new tasks.
|
||||
/// </summary>
|
||||
/// <value>The default is 1000 milliseconds.</value>
|
||||
public long DefaultPollIntervalMs { get; set; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default time-to-live for new tasks, or <see langword="null"/> for unlimited.
|
||||
/// </summary>
|
||||
public TimeSpan? DefaultTimeToLive { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<McpTaskInfo> CreateTaskAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var taskId = Guid.NewGuid().ToString("N");
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var info = new McpTaskInfo(taskId, McpTaskStatus.Working, now, now, DefaultTimeToLive, DefaultPollIntervalMs);
|
||||
_tasks[taskId] = info;
|
||||
|
||||
return Task.FromResult(info);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<McpTaskInfo?> GetTaskAsync(string taskId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_tasks.TryGetValue(taskId, out var info);
|
||||
return Task.FromResult<McpTaskInfo?>(info);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task SetCompletedAsync(string taskId, JsonElement result, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Update(taskId, entry => IsTerminal(entry.Status)
|
||||
? entry
|
||||
: entry with
|
||||
{
|
||||
Status = McpTaskStatus.Completed,
|
||||
Result = result,
|
||||
LastUpdatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task SetFailedAsync(string taskId, JsonElement error, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Update(taskId, entry => IsTerminal(entry.Status)
|
||||
? entry
|
||||
: entry with
|
||||
{
|
||||
Status = McpTaskStatus.Failed,
|
||||
Error = error,
|
||||
LastUpdatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<bool> SetCancelledAsync(string taskId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_tasks.TryGetValue(taskId, out var entry) || IsTerminal(entry.Status))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
bool transitioned = false;
|
||||
Update(taskId, e =>
|
||||
{
|
||||
if (IsTerminal(e.Status))
|
||||
{
|
||||
return e;
|
||||
}
|
||||
|
||||
transitioned = true;
|
||||
return e with { Status = McpTaskStatus.Cancelled, LastUpdatedAt = DateTimeOffset.UtcNow };
|
||||
});
|
||||
|
||||
return Task.FromResult(transitioned);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event Action<InputResponseReceivedEventArgs>? InputResponseReceived;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task ResolveInputRequestsAsync(
|
||||
string taskId,
|
||||
IDictionary<string, InputResponse> inputResponses,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
bool wasTerminal = false;
|
||||
Update(taskId, entry =>
|
||||
{
|
||||
if (IsTerminal(entry.Status))
|
||||
{
|
||||
wasTerminal = true;
|
||||
return entry;
|
||||
}
|
||||
|
||||
var requests = entry.InputRequests as ImmutableDictionary<string, InputRequest>
|
||||
?? entry.InputRequests?.ToImmutableDictionary()
|
||||
?? ImmutableDictionary<string, InputRequest>.Empty;
|
||||
|
||||
foreach (var key in inputResponses.Keys)
|
||||
{
|
||||
requests = requests.Remove(key);
|
||||
}
|
||||
|
||||
var status = requests.IsEmpty ? McpTaskStatus.Working : entry.Status;
|
||||
|
||||
return entry with
|
||||
{
|
||||
InputRequests = requests,
|
||||
Status = status,
|
||||
LastUpdatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
});
|
||||
|
||||
if (wasTerminal)
|
||||
{
|
||||
// Drop responses targeting a terminal task — there are no listeners that can act on them.
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
foreach (var kvp in inputResponses)
|
||||
{
|
||||
InputResponseReceived?.Invoke(new InputResponseReceivedEventArgs
|
||||
{
|
||||
TaskId = taskId,
|
||||
RequestId = kvp.Key,
|
||||
Response = kvp.Value,
|
||||
});
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task SetInputRequestsAsync(
|
||||
string taskId,
|
||||
IDictionary<string, InputRequest> inputRequests,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Update(taskId, entry =>
|
||||
{
|
||||
if (IsTerminal(entry.Status))
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
|
||||
var requests = entry.InputRequests as ImmutableDictionary<string, InputRequest>
|
||||
?? entry.InputRequests?.ToImmutableDictionary()
|
||||
?? ImmutableDictionary<string, InputRequest>.Empty;
|
||||
|
||||
foreach (var kvp in inputRequests)
|
||||
{
|
||||
requests = requests.SetItem(kvp.Key, kvp.Value);
|
||||
}
|
||||
|
||||
return entry with
|
||||
{
|
||||
InputRequests = requests,
|
||||
Status = McpTaskStatus.InputRequired,
|
||||
LastUpdatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static bool IsTerminal(McpTaskStatus status) =>
|
||||
status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled;
|
||||
|
||||
private void Update(string taskId, Func<McpTaskInfo, McpTaskInfo> transform)
|
||||
{
|
||||
SpinWait spin = default;
|
||||
while (true)
|
||||
{
|
||||
if (!_tasks.TryGetValue(taskId, out var current))
|
||||
{
|
||||
throw new InvalidOperationException($"Task '{taskId}' not found.");
|
||||
}
|
||||
|
||||
var updated = transform(current);
|
||||
if (ReferenceEquals(updated, current) || _tasks.TryUpdate(taskId, updated, current))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
spin.SpinOnce();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace ModelContextProtocol.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Provides data for the <see cref="IMcpTaskStore.InputResponseReceived"/> event.
|
||||
/// </summary>
|
||||
public sealed class InputResponseReceivedEventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the task identifier.
|
||||
/// </summary>
|
||||
public required string TaskId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the request identifier that was resolved.
|
||||
/// </summary>
|
||||
public required string RequestId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the response payload.
|
||||
/// </summary>
|
||||
public required InputResponse Response { get; init; }
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace ModelContextProtocol.Server;
|
||||
|
||||
|
|
@ -42,8 +43,10 @@ public sealed class McpRequestFilters
|
|||
/// <see cref="RequestMethods.ToolsCall"/> requests. The handler should implement logic to execute the requested tool and return appropriate results.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Cannot be used together with <see cref="CallToolWithTaskFilters"/>. If both are non-empty at configuration time,
|
||||
/// an <see cref="InvalidOperationException"/> will be thrown.
|
||||
/// Cannot be used together with <see cref="CallToolWithAlternateFilters"/>. If both are non-empty at configuration time,
|
||||
/// an <see cref="InvalidOperationException"/> will be thrown. This can happen indirectly when combining features that
|
||||
/// register different tool-call filter styles, such as authorization filters (which use this collection) and the
|
||||
/// tasks extension (which uses <see cref="CallToolWithAlternateFilters"/>).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public IList<McpRequestFilter<CallToolRequestParams, CallToolResult>> CallToolFilters
|
||||
|
|
@ -56,22 +59,26 @@ public sealed class McpRequestFilters
|
|||
}
|
||||
}
|
||||
|
||||
#pragma warning disable MCPEXP002 // CallToolWithAlternateFilters references the experimental ResultOrAlternate seam
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// These filters wrap the task-augmented call-tool handler whose return type is
|
||||
/// <see cref="ResultOrCreatedTask{TResult}"/>. Use these filters when the server's tool pipeline
|
||||
/// supports returning either an immediate <see cref="CallToolResult"/> or a <see cref="CreateTaskResult"/>
|
||||
/// for asynchronous execution.
|
||||
/// These filters wrap the alternate-result call-tool handler whose return type is
|
||||
/// <see cref="ResultOrAlternate{TResult}"/>. Use these filters when the server's tool pipeline
|
||||
/// supports returning either an immediate <see cref="CallToolResult"/> or an alternate <see cref="Result"/>
|
||||
/// subtype for asynchronous execution.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Cannot be used together with <see cref="CallToolFilters"/>. If both are non-empty at configuration time,
|
||||
/// an <see cref="InvalidOperationException"/> will be thrown.
|
||||
/// an <see cref="InvalidOperationException"/> will be thrown. This can happen indirectly when combining features that
|
||||
/// register different tool-call filter styles, such as the tasks extension (which uses this collection) and
|
||||
/// authorization filters (which use <see cref="CallToolFilters"/>).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public IList<McpRequestFilter<CallToolRequestParams, ResultOrCreatedTask<CallToolResult>>> CallToolWithTaskFilters
|
||||
[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)]
|
||||
public IList<McpRequestFilter<CallToolRequestParams, ResultOrAlternate<CallToolResult>>> CallToolWithAlternateFilters
|
||||
{
|
||||
get => field ??= [];
|
||||
set
|
||||
|
|
@ -80,6 +87,7 @@ public sealed class McpRequestFilters
|
|||
field = value;
|
||||
}
|
||||
}
|
||||
#pragma warning restore MCPEXP002
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the filters for the list-prompts handler pipeline.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,52 @@ public abstract partial class McpServer : McpSession
|
|||
|
||||
private static Dictionary<string, HashSet<string>>? s_elicitAllowedProperties = null;
|
||||
|
||||
/// <summary>
|
||||
/// Ambient interceptor that, when installed, redirects server-initiated requests
|
||||
/// (<see cref="SampleAsync(CreateMessageRequestParams, CancellationToken)"/>,
|
||||
/// <see cref="ElicitAsync(ElicitRequestParams, CancellationToken)"/>, and
|
||||
/// <see cref="RequestRootsAsync(ListRootsRequestParams, CancellationToken)"/>) 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.
|
||||
/// </summary>
|
||||
private static readonly AsyncLocal<Func<string, JsonNode?, CancellationToken, ValueTask<JsonNode?>>?> s_outgoingRequestInterceptor = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the currently installed outgoing-request interceptor for the ambient execution context, if any.
|
||||
/// </summary>
|
||||
internal static Func<string, JsonNode?, CancellationToken, ValueTask<JsonNode?>>? CurrentOutgoingRequestInterceptor => s_outgoingRequestInterceptor.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Installs an interceptor that redirects server-initiated requests for the duration of the
|
||||
/// returned scope on the current asynchronous execution context.
|
||||
/// </summary>
|
||||
/// <param name="interceptor">
|
||||
/// The interceptor invoked for each outgoing request. It receives the request method, the
|
||||
/// pre-serialized request parameters (or <see langword="null"/>), and a cancellation token, and
|
||||
/// returns the serialized result (or <see langword="null"/> to indicate no result).
|
||||
/// </param>
|
||||
/// <returns>An <see cref="IDisposable"/> that restores the previous interceptor when disposed.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="interceptor"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)]
|
||||
public IDisposable InterceptOutgoingRequests(Func<string, JsonNode?, CancellationToken, ValueTask<JsonNode?>> interceptor)
|
||||
{
|
||||
Throw.IfNull(interceptor);
|
||||
|
||||
var previous = s_outgoingRequestInterceptor.Value;
|
||||
s_outgoingRequestInterceptor.Value = interceptor;
|
||||
return new OutgoingRequestInterceptorScope(previous);
|
||||
}
|
||||
|
||||
private sealed class OutgoingRequestInterceptorScope(Func<string, JsonNode?, CancellationToken, ValueTask<JsonNode?>>? previous) : IDisposable
|
||||
{
|
||||
public void Dispose() => s_outgoingRequestInterceptor.Value = previous;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of an <see cref="McpServer"/>.
|
||||
/// </summary>
|
||||
|
|
@ -61,11 +107,6 @@ public abstract partial class McpServer : McpSession
|
|||
/// <see cref="JsonRpcMessageContext.RelatedTransport"/>, which is always open for the duration of
|
||||
/// the request, rather than relying on the optional standalone GET SSE stream.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When called during task-augmented tool execution, this method automatically updates the task
|
||||
/// status to <see cref="McpTaskStatus.InputRequired"/> while waiting for the client response,
|
||||
/// then returns to <see cref="McpTaskStatus.Working"/> when the response is received.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)]
|
||||
public ValueTask<CreateMessageResult> SampleAsync(
|
||||
|
|
@ -74,14 +115,13 @@ public abstract partial class McpServer : McpSession
|
|||
{
|
||||
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 abstract partial class McpServer : McpSession
|
|||
{
|
||||
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 abstract partial class McpServer : McpSession
|
|||
/// <see cref="JsonRpcMessageContext.RelatedTransport"/>, which is always open for the duration of
|
||||
/// the request, rather than relying on the optional standalone GET SSE stream.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When called during task-augmented tool execution, this method automatically updates the task
|
||||
/// status to <see cref="McpTaskStatus.InputRequired"/> while waiting for user input,
|
||||
/// then returns to <see cref="McpTaskStatus.Working"/> when the response is received.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async ValueTask<ElicitResult> ElicitAsync(
|
||||
ElicitRequestParams requestParams,
|
||||
|
|
@ -334,18 +368,15 @@ public abstract partial class McpServer : McpSession
|
|||
{
|
||||
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)
|
||||
// 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 taskResult = await SendRequestViaTaskAsync(taskContext, RequestMethods.ElicitationCreate, requestParams,
|
||||
McpJsonUtilities.JsonContext.Default.ElicitRequestParams,
|
||||
McpJsonUtilities.JsonContext.Default.ElicitResult,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return taskResult ?? new ElicitResult { Action = "cancel" };
|
||||
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 abstract partial class McpServer : McpSession
|
|||
return new ElicitResult<T> { Action = raw.Action, Content = typed };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a task status notification to the connected client.
|
||||
/// </summary>
|
||||
/// <param name="notificationParams">The task status notification parameters to send.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task that represents the asynchronous send operation.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="notificationParams"/> is <see langword="null"/>.</exception>
|
||||
public Task SendTaskStatusNotificationAsync(
|
||||
TaskStatusNotificationParams notificationParams,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(notificationParams);
|
||||
|
||||
return SendNotificationAsync(
|
||||
NotificationMethods.TaskStatusNotification,
|
||||
notificationParams,
|
||||
McpJsonUtilities.JsonContext.Default.TaskStatusNotificationParams,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a request schema for elicitation based on the public serializable properties of <paramref name="type"/>.
|
||||
/// </summary>
|
||||
|
|
@ -592,84 +603,37 @@ public abstract partial class McpServer : McpSession
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="McpTaskStatus.InputRequired"/> state instead of direct JSON-RPC messages.
|
||||
/// </summary>
|
||||
/// <param name="taskId">The task ID in the store.</param>
|
||||
/// <param name="store">The task store to write input requests to.</param>
|
||||
/// <returns>An <see cref="IDisposable"/> that restores the previous context when disposed.</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When executing inside a task scope, capability negotiation checks (such as
|
||||
/// When an interceptor is installed, capability negotiation checks (such as
|
||||
/// <see cref="ThrowIfSamplingUnsupported"/>, <see cref="ThrowIfRootsUnsupported"/>, and
|
||||
/// <see cref="ThrowIfElicitationUnsupported"/>) 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 <c>tasks/get</c>.
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
private async ValueTask<TResponse> SendRequestViaTaskAsync<TRequest, TResponse>(
|
||||
McpTaskExecutionContext taskContext,
|
||||
private async ValueTask<TResponse> SendRequestViaInterceptorAsync<TRequest, TResponse>(
|
||||
Func<string, JsonNode?, CancellationToken, ValueTask<JsonNode?>> interceptor,
|
||||
string method,
|
||||
TRequest request,
|
||||
JsonTypeInfo<TRequest> requestTypeInfo,
|
||||
JsonTypeInfo<TResponse> responseTypeInfo,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var requestId = Guid.NewGuid().ToString("N");
|
||||
var paramsJson = JsonSerializer.SerializeToElement(request, requestTypeInfo);
|
||||
|
||||
var inputRequest = new InputRequest
|
||||
var paramsNode = JsonSerializer.SerializeToNode(request, requestTypeInfo);
|
||||
var resultNode = await interceptor(method, paramsNode, cancellationToken).ConfigureAwait(false);
|
||||
if (resultNode is null)
|
||||
{
|
||||
Method = method,
|
||||
Params = paramsJson,
|
||||
};
|
||||
|
||||
var tcs = new TaskCompletionSource<InputResponse>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
void handler(InputResponseReceivedEventArgs args)
|
||||
{
|
||||
if (args.TaskId == taskContext.TaskId && args.RequestId == requestId)
|
||||
{
|
||||
tcs.TrySetResult(args.Response);
|
||||
}
|
||||
// A null result cannot be deserialized into a concrete TResponse (e.g. SampleAsync's
|
||||
// CreateMessageResult or RequestRootsAsync's ListRootsResult). Returning default! here
|
||||
// would hand callers a null response typed as non-null, deferring the failure to a
|
||||
// confusing NullReferenceException at the use site. Fail fast with a clear message.
|
||||
// Callers that can tolerate no result (such as ElicitAsync) do not route through this
|
||||
// helper and handle null themselves.
|
||||
throw new McpException($"The outgoing-request interceptor returned no result for the '{method}' request.");
|
||||
}
|
||||
|
||||
taskContext.Store.InputResponseReceived += handler;
|
||||
try
|
||||
{
|
||||
await taskContext.Store.SetInputRequestsAsync(
|
||||
taskContext.TaskId,
|
||||
new Dictionary<string, InputRequest> { [requestId] = inputRequest },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var response = await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return response.Deserialize(responseTypeInfo)!;
|
||||
}
|
||||
finally
|
||||
{
|
||||
taskContext.Store.InputResponseReceived -= handler;
|
||||
}
|
||||
return resultNode.Deserialize(responseTypeInfo)!;
|
||||
}
|
||||
|
||||
private void ThrowIfElicitationUnsupported(ElicitRequestParams request)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace ModelContextProtocol.Server;
|
||||
|
||||
|
|
@ -36,25 +37,26 @@ public sealed class McpServerHandlers
|
|||
/// </remarks>
|
||||
public McpRequestHandler<ListToolsRequestParams, ListToolsResult>? ListToolsHandler { get; set; }
|
||||
|
||||
#pragma warning disable MCPEXP002 // CallToolHandler and CallToolWithAlternateHandler reference the experimental ResultOrAlternate seam
|
||||
/// <summary>
|
||||
/// Gets or sets the handler for <see cref="RequestMethods.ToolsCall"/> requests.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This handler is invoked when a client makes a call to a tool that isn't found in the <see cref="McpServerTool"/> collection.
|
||||
/// The handler should implement logic to execute the requested tool and return appropriate results.
|
||||
/// Use <see cref="CallToolWithTaskHandler"/> instead if the tool may return a <see cref="CreateTaskResult"/>
|
||||
/// for asynchronous execution.
|
||||
/// Use <see cref="CallToolWithAlternateHandler"/> instead if the tool may return an alternate result
|
||||
/// for the caller to handle.
|
||||
/// </remarks>
|
||||
/// <exception cref="InvalidOperationException"><see cref="CallToolWithTaskHandler"/> is already set.</exception>
|
||||
/// <exception cref="InvalidOperationException"><see cref="CallToolWithAlternateHandler"/> is already set.</exception>
|
||||
public McpRequestHandler<CallToolRequestParams, CallToolResult>? 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 +64,20 @@ public sealed class McpServerHandlers
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the handler for <see cref="RequestMethods.ToolsCall"/> requests with task support.
|
||||
/// Gets or sets the handler for <see cref="RequestMethods.ToolsCall"/> requests with alternate result support.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This handler is invoked when a client makes a call to a tool, allowing the tool to return either
|
||||
/// a <see cref="CallToolResult"/> for immediate results or a <see cref="CreateTaskResult"/> for
|
||||
/// long-running asynchronous operations.
|
||||
/// a <see cref="CallToolResult"/> for immediate results or an alternate <see cref="Result"/> subtype.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Cannot be set if <see cref="CallToolHandler"/> is already set.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <exception cref="InvalidOperationException"><see cref="CallToolHandler"/> is already set.</exception>
|
||||
public McpRequestHandler<CallToolRequestParams, ResultOrCreatedTask<CallToolResult>>? CallToolWithTaskHandler
|
||||
[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)]
|
||||
public McpRequestHandler<CallToolRequestParams, ResultOrAlternate<CallToolResult>>? CallToolWithAlternateHandler
|
||||
{
|
||||
get;
|
||||
set
|
||||
|
|
@ -83,12 +85,13 @@ public sealed class McpServerHandlers
|
|||
if (value is not null && CallToolHandler is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot set {nameof(CallToolWithTaskHandler)} when {nameof(CallToolHandler)} is already set. Only one call tool handler may be configured.");
|
||||
$"Cannot set {nameof(CallToolWithAlternateHandler)} when {nameof(CallToolHandler)} is already set. Only one call tool handler may be configured.");
|
||||
}
|
||||
|
||||
field = value;
|
||||
}
|
||||
}
|
||||
#pragma warning restore MCPEXP002
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the handler for <see cref="RequestMethods.PromptsList"/> requests.
|
||||
|
|
@ -202,74 +205,6 @@ public sealed class McpServerHandlers
|
|||
[Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)]
|
||||
public McpRequestHandler<SetLevelRequestParams, EmptyResult>? SetLoggingLevelHandler { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the handler for <see cref="RequestMethods.TasksGet"/> requests.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This handler is invoked when a client polls for the current state of a task.
|
||||
/// The handler should return the appropriate <see cref="GetTaskResult"/> subtype
|
||||
/// based on the task's current status (for example, <see cref="WorkingTaskResult"/>,
|
||||
/// <see cref="InputRequiredTaskResult"/>, <see cref="CompletedTaskResult"/>,
|
||||
/// <see cref="CancelledTaskResult"/>, or <see cref="FailedTaskResult"/>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Setting <see cref="McpServerOptions.TaskStore"/> is the recommended way to wire all three
|
||||
/// task lifecycle handlers (<see cref="GetTaskHandler"/>, <see cref="UpdateTaskHandler"/>,
|
||||
/// and <see cref="CancelTaskHandler"/>) from a single source while still allowing explicit
|
||||
/// handlers to override any slot. If <see cref="CallToolWithTaskHandler"/> can return a
|
||||
/// <see cref="CreateTaskResult"/> but no <see cref="GetTaskHandler"/> is configured (either
|
||||
/// directly or via a task store), the server throws <see cref="InvalidOperationException"/>
|
||||
/// when processing the request so misconfigured deployments fail loudly instead of producing
|
||||
/// unpollable tasks.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public McpRequestHandler<GetTaskRequestParams, GetTaskResult>? GetTaskHandler { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the handler for <see cref="RequestMethods.TasksUpdate"/> requests.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This handler is invoked when a client provides input responses for a task
|
||||
/// that is in the <see cref="McpTaskStatus.InputRequired"/> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Prefer configuring <see cref="McpServerOptions.TaskStore"/> instead of setting this
|
||||
/// handler directly; the default implementation built from the store dispatches to
|
||||
/// <see cref="IMcpTaskStore.ResolveInputRequestsAsync"/> and raises
|
||||
/// <see cref="IMcpTaskStore.InputResponseReceived"/> to wake any pending
|
||||
/// <see cref="McpServer.ElicitAsync(ModelContextProtocol.Protocol.ElicitRequestParams, CancellationToken)"/>
|
||||
/// or <see cref="McpServer.SampleAsync(ModelContextProtocol.Protocol.CreateMessageRequestParams, CancellationToken)"/>
|
||||
/// calls executing inside a task scope.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public McpRequestHandler<UpdateTaskRequestParams, UpdateTaskResult>? UpdateTaskHandler { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the handler for <see cref="RequestMethods.TasksCancel"/> requests.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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 <see cref="CancelTaskResult"/>, even if the task is
|
||||
/// unknown, already terminal, or cannot actually be stopped. Whether the task transitions
|
||||
/// to <see cref="McpTaskStatus.Cancelled"/> is up to the implementation.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Prefer configuring <see cref="McpServerOptions.TaskStore"/> instead of setting this
|
||||
/// handler directly; the default implementation built from the store calls
|
||||
/// <see cref="IMcpTaskStore.SetCancelledAsync"/> and signals the per-task
|
||||
/// <see cref="CancellationTokenSource"/> so the tool's <see cref="CancellationToken"/>
|
||||
/// observes cancellation.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public McpRequestHandler<CancelTaskRequestParams, CancelTaskResult>? CancelTaskHandler { get; set; }
|
||||
|
||||
/// <summary>Gets or sets notification handlers to register with the server.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
private readonly string[] _initializeHandshakeProtocolVersions;
|
||||
private readonly string[] _perRequestMetadataProtocolVersions;
|
||||
private readonly SemaphoreSlim _disposeLock = new(1, 1);
|
||||
private readonly ConcurrentDictionary<string, CancellationTokenSource> _taskCancellationSources = new();
|
||||
private readonly ConcurrentDictionary<string, MrtrContinuation> _mrtrContinuations = new();
|
||||
private readonly ConcurrentDictionary<RequestId, MrtrContext> _mrtrContextsByRequestId = new();
|
||||
|
||||
|
|
@ -109,8 +108,8 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
ConfigureCompletion(options);
|
||||
ConfigureSubscriptions(options);
|
||||
ConfigureExperimentalAndExtensions(options);
|
||||
ConfigureTasks(options);
|
||||
ConfigureMrtr();
|
||||
ConfigureCustomRequestHandlers(options);
|
||||
|
||||
// Register any notification handlers that were provided.
|
||||
if (options.Handlers.NotificationHandlers is { } notificationHandlers)
|
||||
|
|
@ -409,9 +408,6 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
|
||||
if (!usesPerRequestMetadata &&
|
||||
request.Method is RequestMethods.SubscriptionsListen
|
||||
or RequestMethods.TasksGet
|
||||
or RequestMethods.TasksUpdate
|
||||
or RequestMethods.TasksCancel
|
||||
or RequestMethods.ServerDiscover)
|
||||
{
|
||||
throw new McpProtocolException(
|
||||
|
|
@ -521,13 +517,6 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
|
||||
_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
|
||||
|
|
@ -1001,117 +990,49 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
return result;
|
||||
}
|
||||
|
||||
private void ConfigureTasks(McpServerOptions options)
|
||||
{
|
||||
var getTaskHandler = options.Handlers.GetTaskHandler;
|
||||
var updateTaskHandler = options.Handlers.UpdateTaskHandler;
|
||||
var cancelTaskHandler = options.Handlers.CancelTaskHandler;
|
||||
var taskStore = options.TaskStore;
|
||||
|
||||
// If a task store is provided, wire up handlers from it for any that aren't explicitly set.
|
||||
if (taskStore is not null)
|
||||
{
|
||||
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<string, InputResponse>();
|
||||
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();
|
||||
};
|
||||
}
|
||||
|
||||
if (getTaskHandler is null && updateTaskHandler is null && cancelTaskHandler is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
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 an initialize-handshake protocol version. The handlers
|
||||
// stay registered so a dual-path 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<string, object>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a tasks/* request handler so it throws <see cref="McpErrorCode.MethodNotFound"/> 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.
|
||||
/// </summary>
|
||||
private McpRequestHandler<TParams, TResult> GateTaskMethodToJuly2026OrLaterProtocol<TParams, TResult>(
|
||||
McpRequestHandler<TParams, TResult> 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)
|
||||
{
|
||||
ServerCapabilities.Experimental = options.Capabilities?.Experimental;
|
||||
ServerCapabilities.Extensions = options.Capabilities?.Extensions;
|
||||
}
|
||||
|
||||
private void ConfigureCustomRequestHandlers(McpServerOptions options)
|
||||
{
|
||||
#pragma warning disable MCPEXP002
|
||||
if (options.RequestHandlers is not { Count: > 0 } customHandlers)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var entry in customHandlers)
|
||||
{
|
||||
if (string.IsNullOrEmpty(entry.Method))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"A custom request handler registered through {nameof(McpServerOptions)}.{nameof(McpServerOptions.RequestHandlers)} has a null or empty {nameof(McpServerRequestHandler.Method)}.");
|
||||
}
|
||||
|
||||
// Custom handlers are registered after all built-in handlers, so a method already present
|
||||
// belongs to a built-in method (e.g. initialize, tools/call) or an earlier custom handler.
|
||||
// Silently overwriting it would bypass the built-in handler's filters and protocol gating,
|
||||
// so reject the collision instead.
|
||||
if (_requestHandlers.ContainsKey(entry.Method))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"A custom request handler registered through {nameof(McpServerOptions)}.{nameof(McpServerOptions.RequestHandlers)} " +
|
||||
$"uses the method '{entry.Method}', which is already handled by the server. Custom handlers cannot replace built-in methods or other custom handlers.");
|
||||
}
|
||||
|
||||
SetRawHandler(entry.Method, entry.Handler);
|
||||
}
|
||||
#pragma warning restore MCPEXP002
|
||||
}
|
||||
|
||||
private void SetRawHandler(string method, Func<JsonRpcRequest, CancellationToken, ValueTask<JsonNode?>> handler)
|
||||
{
|
||||
_requestHandlers[method] = (request, ct) => handler(request, ct).AsTask();
|
||||
}
|
||||
|
||||
private void ConfigureResources(McpServerOptions options)
|
||||
{
|
||||
var listResourcesHandler = options.Handlers.ListResourcesHandler;
|
||||
|
|
@ -1377,15 +1298,16 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
McpJsonUtilities.JsonContext.Default.GetPromptResult);
|
||||
}
|
||||
|
||||
#pragma warning disable MCPEXP002 // tool dispatch wires up the experimental alternate call-tool handler and filters
|
||||
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;
|
||||
|
|
@ -1397,17 +1319,22 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
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)}) tool-call filters or handlers. " +
|
||||
$"These two styles cannot currently be composed on the same server. " +
|
||||
$"This most commonly happens when combining features that register different tool-call filter styles, " +
|
||||
$"for example AddAuthorizationFilters() (which registers a {nameof(McpRequestFilters.CallToolFilters)} filter) together with " +
|
||||
$"WithTasks() (which registers a {nameof(McpRequestFilters.CallToolWithAlternateFilters)} filter). " +
|
||||
$"Configure only one style, or avoid combining features that require different styles.");
|
||||
}
|
||||
|
||||
// Handle tools provided via DI by augmenting the list handler.
|
||||
|
|
@ -1449,32 +1376,32 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
|
||||
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.
|
||||
|
|
@ -1494,114 +1421,11 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
|
||||
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(
|
||||
|
|
@ -1610,94 +1434,13 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
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<string, InputRequest> dict
|
||||
? dict
|
||||
: info.InputRequests?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)
|
||||
?? new Dictionary<string, InputRequest>(),
|
||||
ResultType = "complete",
|
||||
},
|
||||
_ => throw new InvalidOperationException($"Unknown task status: {info.Status}"),
|
||||
};
|
||||
|
||||
private static async ValueTask<ResultOrCreatedTask<CallToolResult>> InvokeToolAsTask(
|
||||
private static async ValueTask<ResultOrAlternate<CallToolResult>> InvokeToolWithAlternate(
|
||||
McpServerTool tool,
|
||||
RequestContext<CallToolRequestParams> request,
|
||||
CancellationToken cancellationToken)
|
||||
|
|
@ -1748,7 +1491,7 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
}
|
||||
};
|
||||
|
||||
private McpRequestFilter<CallToolRequestParams, ResultOrCreatedTask<CallToolResult>> BuildInitialTaskToolFilter(
|
||||
private McpRequestFilter<CallToolRequestParams, ResultOrAlternate<CallToolResult>> BuildInitialAlternateToolFilter(
|
||||
McpServerPrimitiveCollection<McpServerTool>? tools) => handler =>
|
||||
async (request, cancellationToken) =>
|
||||
{
|
||||
|
|
@ -1761,7 +1504,7 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
try
|
||||
{
|
||||
var result = await handler(request, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.IsTask)
|
||||
if (!result.IsAlternate)
|
||||
{
|
||||
ToolCallCompleted(request.Params?.Name ?? string.Empty, result.Result!.IsError is true);
|
||||
}
|
||||
|
|
@ -1794,6 +1537,7 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
};
|
||||
}
|
||||
};
|
||||
#pragma warning restore MCPEXP002
|
||||
|
||||
private void ConfigureLogging(McpServerOptions options)
|
||||
{
|
||||
|
|
@ -1939,26 +1683,19 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
requestTypeInfo, responseTypeInfo);
|
||||
}
|
||||
|
||||
private void SetTaskAugmentedHandler<TParams, TResult>(
|
||||
#pragma warning disable MCPEXP002 // SetWithAlternateHandler wraps the experimental ResultOrAlternate seam
|
||||
private void SetWithAlternateHandler<TParams, TResult>(
|
||||
string method,
|
||||
McpRequestHandler<TParams, ResultOrCreatedTask<TResult>> handler,
|
||||
McpRequestHandler<TParams, ResultOrAlternate<TResult>> handler,
|
||||
JsonTypeInfo<TParams> requestTypeInfo,
|
||||
JsonTypeInfo<TResult> responseTypeInfo,
|
||||
JsonTypeInfo<CreateTaskResult> taskResultTypeInfo)
|
||||
JsonTypeInfo<TResult> responseTypeInfo)
|
||||
where TResult : Result
|
||||
{
|
||||
var innerHandler = handler;
|
||||
handler = async (request, cancellationToken) =>
|
||||
{
|
||||
var result = await innerHandler(request, cancellationToken).ConfigureAwait(false);
|
||||
if (result.IsTask)
|
||||
{
|
||||
if (result.TaskCreated is { ResultType: null } taskCreated)
|
||||
{
|
||||
taskCreated.ResultType = "task";
|
||||
}
|
||||
}
|
||||
else if (result.Result is { ResultType: null } immediateResult)
|
||||
if (!result.IsAlternate && result.Result is { ResultType: null } immediateResult)
|
||||
{
|
||||
immediateResult.ResultType = "complete";
|
||||
}
|
||||
|
|
@ -1966,11 +1703,12 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
return result;
|
||||
};
|
||||
|
||||
_requestHandlers.SetTaskAugmented(method,
|
||||
_requestHandlers.SetWithAlternate(method,
|
||||
(request, jsonRpcRequest, cancellationToken) =>
|
||||
InvokeHandlerAsync(handler, request, jsonRpcRequest, cancellationToken),
|
||||
requestTypeInfo, responseTypeInfo, taskResultTypeInfo);
|
||||
requestTypeInfo, responseTypeInfo);
|
||||
}
|
||||
#pragma warning restore MCPEXP002
|
||||
|
||||
private static McpRequestHandler<TParams, TResult> BuildFilterPipeline<TParams, TResult>(
|
||||
McpRequestHandler<TParams, TResult> baseHandler,
|
||||
|
|
@ -1992,15 +1730,6 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
return current;
|
||||
}
|
||||
|
||||
// Per SEP-2663 §51, the client opts in to the tasks extension on a per-request basis
|
||||
// via the SEP-2575 capabilities envelope:
|
||||
// _meta/io.modelcontextprotocol/clientCapabilities/extensions/io.modelcontextprotocol/tasks = {}
|
||||
private static bool HasTaskExtensionOptIn(JsonObject? meta) =>
|
||||
meta is not null &&
|
||||
meta[MetaKeys.ClientCapabilities] is JsonObject caps &&
|
||||
caps["extensions"] is JsonObject exts &&
|
||||
exts.ContainsKey(McpExtensions.Tasks);
|
||||
|
||||
private JsonRpcMessageFilter BuildMessageFilterPipeline(IList<McpMessageFilter> filters)
|
||||
{
|
||||
if (filters.Count == 0)
|
||||
|
|
@ -2075,12 +1804,10 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
/// </summary>
|
||||
internal bool HasStatefulTransport() =>
|
||||
_sessionTransport is not StreamableHttpServerTransport { Stateless: true };
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> when the given request was negotiated under the 2026-07-28 or later protocol
|
||||
/// revision, derived from the per-request <c>_meta</c>/<c>MCP-Protocol-Version</c> 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.
|
||||
/// </summary>
|
||||
private bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request) =>
|
||||
IsJuly2026OrLaterProtocolRequest(request?.Context);
|
||||
|
|
@ -2109,75 +1836,92 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
|
||||
for (int retry = 0; ; retry++)
|
||||
{
|
||||
InputRequiredResult inputRequiredResult;
|
||||
Exception? inputRequiredException = null;
|
||||
|
||||
try
|
||||
{
|
||||
return await handler(request, cancellationToken).ConfigureAwait(false);
|
||||
var result = await handler(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// A handler can surface an input-required result two ways: by throwing InputRequiredException,
|
||||
// or by RETURNING an InputRequiredResult through the alternate result path (ResultOrAlternate).
|
||||
// Normalize both forms so a client that doesn't natively support MRTR gets the same server-side
|
||||
// resolution either way.
|
||||
if (GetReturnedInputRequiredResult(result) is not { } returnedInputRequired)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
inputRequiredResult = returnedInputRequired;
|
||||
}
|
||||
catch (InputRequiredException ex)
|
||||
{
|
||||
// If the client natively supports MRTR, serialize and return directly -
|
||||
// the client will drive the retry loop.
|
||||
if (ClientSupportsMrtr())
|
||||
{
|
||||
return SerializeInputRequiredResult(ex.Result);
|
||||
}
|
||||
|
||||
// In stateless mode without MRTR, the server can't resolve input requests via
|
||||
// JSON-RPC (no persistent session for server-to-client requests), and the client
|
||||
// won't recognize the InputRequiredResult. This is the one unsupported configuration.
|
||||
if (!HasStatefulTransport())
|
||||
{
|
||||
throw new McpException(
|
||||
"A tool handler returned an incomplete result, but the server is stateless and the client does not support MRTR. " +
|
||||
"MRTR-native tools require either an MRTR-capable client or a stateful server for backward-compatible resolution.", ex);
|
||||
}
|
||||
|
||||
// Backcompat: resolve input requests via standard JSON-RPC calls and retry the handler.
|
||||
if (ex.Result.InputRequests is not { Count: > 0 } inputRequests)
|
||||
{
|
||||
throw new McpException(
|
||||
"A tool handler returned an incomplete result without input requests, and the client does not support MRTR.", ex);
|
||||
}
|
||||
|
||||
if (retry >= MaxRetries)
|
||||
{
|
||||
throw new McpException(
|
||||
$"MRTR-native tool exceeded {MaxRetries} retry rounds without completing.", ex);
|
||||
}
|
||||
|
||||
// Resolve each input request by sending the corresponding JSON-RPC call to the client.
|
||||
// Route the outgoing requests via the same DestinationBoundMcpServer used for normal tool
|
||||
// handlers, so they go through the POST's response stream (RelatedTransport) rather than
|
||||
// the session-level transport. Without this, the messages can race with the client's GET
|
||||
// stream startup and be silently dropped by StreamableHttpServerTransport.SendMessageAsync
|
||||
// when no GET request has arrived yet.
|
||||
var destinationServer = CreateDestinationBoundServer(request);
|
||||
var inputResponses = await ResolveInputRequestsAsync(destinationServer, inputRequests, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Reconstruct request params with inputResponses and requestState for the retry.
|
||||
var paramsObj = request.Params?.DeepClone() as JsonObject ?? new JsonObject();
|
||||
paramsObj["inputResponses"] = JsonSerializer.SerializeToNode(
|
||||
(IDictionary<string, InputResponse>)inputResponses, McpJsonUtilities.JsonContext.Default.IDictionaryStringInputResponse);
|
||||
|
||||
if (ex.Result.RequestState is { } requestState)
|
||||
{
|
||||
paramsObj["requestState"] = requestState;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Strip any stale requestState carried over from the previous round's clone so
|
||||
// the next tool invocation doesn't see a continuation token the current round is not using.
|
||||
paramsObj.Remove("requestState");
|
||||
}
|
||||
|
||||
request = new JsonRpcRequest
|
||||
{
|
||||
Id = request.Id,
|
||||
Method = request.Method,
|
||||
Params = paramsObj,
|
||||
Context = request.Context,
|
||||
};
|
||||
inputRequiredResult = ex.Result;
|
||||
inputRequiredException = ex;
|
||||
}
|
||||
|
||||
// If the client natively supports MRTR, serialize and return directly -
|
||||
// the client will drive the retry loop.
|
||||
if (ClientSupportsMrtr())
|
||||
{
|
||||
return SerializeInputRequiredResult(inputRequiredResult);
|
||||
}
|
||||
|
||||
// In stateless mode without MRTR, the server can't resolve input requests via
|
||||
// JSON-RPC (no persistent session for server-to-client requests), and the client
|
||||
// won't recognize the InputRequiredResult. This is the one unsupported configuration.
|
||||
if (!HasStatefulTransport())
|
||||
{
|
||||
throw new McpException(
|
||||
"A tool handler returned an incomplete result, but the server is stateless and the client does not support MRTR. " +
|
||||
"MRTR-native tools require either an MRTR-capable client or a stateful server for backward-compatible resolution.", inputRequiredException);
|
||||
}
|
||||
|
||||
// Backcompat: resolve input requests via standard JSON-RPC calls and retry the handler.
|
||||
if (inputRequiredResult.InputRequests is not { Count: > 0 } inputRequests)
|
||||
{
|
||||
throw new McpException(
|
||||
"A tool handler returned an incomplete result without input requests, and the client does not support MRTR.", inputRequiredException);
|
||||
}
|
||||
|
||||
if (retry >= MaxRetries)
|
||||
{
|
||||
throw new McpException(
|
||||
$"MRTR-native tool exceeded {MaxRetries} retry rounds without completing.", inputRequiredException);
|
||||
}
|
||||
|
||||
// Resolve each input request by sending the corresponding JSON-RPC call to the client.
|
||||
// Route the outgoing requests via the same DestinationBoundMcpServer used for normal tool
|
||||
// handlers, so they go through the POST's response stream (RelatedTransport) rather than
|
||||
// the session-level transport. Without this, the messages can race with the client's GET
|
||||
// stream startup and be silently dropped by StreamableHttpServerTransport.SendMessageAsync
|
||||
// when no GET request has arrived yet.
|
||||
var destinationServer = CreateDestinationBoundServer(request);
|
||||
var inputResponses = await ResolveInputRequestsAsync(destinationServer, inputRequests, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Reconstruct request params with inputResponses and requestState for the retry.
|
||||
var paramsObj = request.Params?.DeepClone() as JsonObject ?? new JsonObject();
|
||||
paramsObj["inputResponses"] = JsonSerializer.SerializeToNode(
|
||||
(IDictionary<string, InputResponse>)inputResponses, McpJsonUtilities.JsonContext.Default.IDictionaryStringInputResponse);
|
||||
|
||||
if (inputRequiredResult.RequestState is { } requestState)
|
||||
{
|
||||
paramsObj["requestState"] = requestState;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Strip any stale requestState carried over from the previous round's clone so
|
||||
// the next tool invocation doesn't see a continuation token the current round is not using.
|
||||
paramsObj.Remove("requestState");
|
||||
}
|
||||
|
||||
request = new JsonRpcRequest
|
||||
{
|
||||
Id = request.Id,
|
||||
Method = request.Method,
|
||||
Params = paramsObj,
|
||||
Context = request.Context,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2265,6 +2009,24 @@ internal sealed partial class McpServerImpl : McpServer
|
|||
private static JsonNode? SerializeInputRequiredResult(InputRequiredResult inputRequiredResult) =>
|
||||
JsonSerializer.SerializeToNode(inputRequiredResult, McpJsonUtilities.JsonContext.Default.InputRequiredResult);
|
||||
|
||||
/// <summary>
|
||||
/// Detects an <see cref="InputRequiredResult"/> that a handler surfaced by RETURNING it through the alternate
|
||||
/// result path (rather than throwing <see cref="InputRequiredException"/>), so both forms can be resolved
|
||||
/// identically for clients that don't natively support MRTR. Returns <see langword="null"/> for any other result.
|
||||
/// </summary>
|
||||
private static InputRequiredResult? GetReturnedInputRequiredResult(JsonNode? result)
|
||||
{
|
||||
if (result is JsonObject resultObject &&
|
||||
resultObject.TryGetPropertyValue("resultType", out var resultTypeNode) &&
|
||||
resultTypeNode?.GetValueKind() == JsonValueKind.String &&
|
||||
resultTypeNode.GetValue<string>() == "input_required")
|
||||
{
|
||||
return JsonSerializer.Deserialize(result, McpJsonUtilities.JsonContext.Default.InputRequiredResult);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps MRTR-eligible request handlers so that when a handler calls ElicitAsync/SampleAsync/RequestRootsAsync,
|
||||
/// an <see cref="InputRequiredResult"/> is returned early and the handler is suspended until the retry arrives.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
#pragma warning disable MCPEXP001
|
||||
#pragma warning disable MCPEXP001, MCPEXP002
|
||||
|
||||
namespace ModelContextProtocol.Server;
|
||||
|
||||
|
|
@ -205,17 +205,18 @@ public sealed class McpServerOptions
|
|||
public int MaxSamplingOutputTokens { get; set; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the task store for managing asynchronous task executions.
|
||||
/// Gets or sets custom request handlers to register with the server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When set, the server automatically enables the <c>io.modelcontextprotocol/tasks</c> extension
|
||||
/// and wires up <c>tasks/get</c>, <c>tasks/update</c>, and <c>tasks/cancel</c> handlers backed by this store.
|
||||
/// Tool executions from clients that signal task support will be wrapped in tasks via the store.
|
||||
/// Each <see cref="McpServerRequestHandler"/> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If explicit task handlers are also set on <see cref="Handlers"/>, the explicit handlers take precedence.
|
||||
/// Handlers registered here take precedence over built-in handlers for the same method.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public IMcpTaskStore? TaskStore { get; set; }
|
||||
[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)]
|
||||
public IList<McpServerRequestHandler>? RequestHandlers { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ModelContextProtocol.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a custom request handler that can be registered with the MCP server to handle
|
||||
/// arbitrary JSON-RPC methods.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Custom request handlers are registered via <see cref="McpServerOptions.RequestHandlers"/> and
|
||||
/// are invoked when a JSON-RPC request with the matching <see cref="Method"/> is received.
|
||||
/// The handler receives the raw <see cref="JsonRpcRequest"/> and returns a serialized
|
||||
/// <see cref="JsonNode"/> response, giving extensions full control over request/response serialization.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)]
|
||||
public sealed class McpServerRequestHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the JSON-RPC method name this handler responds to.
|
||||
/// </summary>
|
||||
public required string Method { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the handler function that processes incoming requests for the specified method.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The handler receives the full <see cref="JsonRpcRequest"/> and a <see cref="CancellationToken"/>,
|
||||
/// and returns a serialized <see cref="JsonNode"/> response (or <see langword="null"/> for void methods).
|
||||
/// </remarks>
|
||||
public required Func<JsonRpcRequest, CancellationToken, ValueTask<JsonNode?>> Handler { get; init; }
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
namespace ModelContextProtocol.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Provides ambient context when a tool is executing as a background task.
|
||||
/// When established, calls to <see cref="McpServer.ElicitAsync(ModelContextProtocol.Protocol.ElicitRequestParams, CancellationToken)"/>,
|
||||
/// <see cref="McpServer.SampleAsync(ModelContextProtocol.Protocol.CreateMessageRequestParams, CancellationToken)"/>,
|
||||
/// and <see cref="McpServer.RequestRootsAsync(ModelContextProtocol.Protocol.ListRootsRequestParams, CancellationToken)"/>
|
||||
/// are redirected through the task store as input requests rather than sent directly to the client.
|
||||
/// </summary>
|
||||
internal sealed class McpTaskExecutionContext
|
||||
{
|
||||
internal static readonly AsyncLocal<McpTaskExecutionContext?> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ModelContextProtocol.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the state of a task in an <see cref="IMcpTaskStore"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the store's representation of a task, decoupled from the MCP protocol wire types.
|
||||
/// The server infrastructure maps <see cref="McpTaskInfo"/> to the appropriate protocol response
|
||||
/// types (<see cref="CreateTaskResult"/>, <see cref="GetTaskResult"/>) when communicating with clients.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record McpTaskInfo(
|
||||
string TaskId,
|
||||
McpTaskStatus Status,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset LastUpdatedAt,
|
||||
TimeSpan? TimeToLive = null,
|
||||
long? PollIntervalMs = null,
|
||||
string? StatusMessage = null,
|
||||
JsonElement? Result = null,
|
||||
JsonElement? Error = null,
|
||||
IReadOnlyDictionary<string, InputRequest>? InputRequests = null);
|
||||
Loading…
Reference in a new issue