using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ModelContextProtocol.Protocol; using System.Collections.Concurrent; using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.Server; /// #pragma warning disable MCPEXP001, MCPEXP002 internal sealed partial class McpServerImpl : McpServer { internal static Implementation DefaultImplementation { get; } = new() { Name = AssemblyNameHelper.DefaultAssemblyName.Name ?? nameof(McpServer), Version = AssemblyNameHelper.DefaultAssemblyName.Version?.ToString() ?? "1.0.0", }; private readonly ILogger _logger; private readonly ITransport _sessionTransport; private readonly bool _servicesScopePerRequest; private readonly List _disposables = []; private readonly NotificationHandlers _notificationHandlers; 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(); // Track MRTR handler tasks using the same inFlightCount + TCS pattern as // McpSessionHandler.ProcessMessagesCoreAsync. Starts at 1 for DisposeAsync itself. private int _mrtrInFlightCount = 1; private readonly TaskCompletionSource _allMrtrHandlersCompleted = new(TaskCreationOptions.RunContinuationsAsynchronously); private ClientCapabilities? _clientCapabilities; private Implementation? _clientInfo; private readonly string _serverOnlyEndpointName; private string? _negotiatedProtocolVersion; private string _endpointName; private int _started; private bool _disposed; /// Holds a boxed value for the server. /// /// Initialized to non-null the first time SetLevel is used. This is stored as a strong box /// rather than a nullable to be able to manipulate it atomically. /// private StrongBox? _loggingLevel; /// /// Creates a new instance of . /// /// Transport to use for the server representing an already-established session. /// Configuration options for this server, including capabilities. /// Make sure to accurately reflect exactly what capabilities the server supports and does not support. /// Logger factory to use for logging /// Optional service provider to use for dependency injection /// The server was incorrectly configured. public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFactory? loggerFactory, IServiceProvider? serviceProvider) #pragma warning restore MCPEXP002 { Throw.IfNull(transport); Throw.IfNull(options); _sessionTransport = transport; ServerOptions = options; Services = serviceProvider; _serverOnlyEndpointName = $"Server ({options.ServerInfo?.Name ?? DefaultImplementation.Name} {options.ServerInfo?.Version ?? DefaultImplementation.Version})"; _endpointName = _serverOnlyEndpointName; _servicesScopePerRequest = options.ScopeRequests; _logger = loggerFactory?.CreateLogger() ?? NullLogger.Instance; _clientInfo = options.KnownClientInfo; _clientCapabilities = options.KnownClientCapabilities; UpdateEndpointNameWithClientInfo(); _notificationHandlers = new(); _requestHandlers = []; // Configure all request handlers based on the supplied options. ServerCapabilities = new(); ConfigureInitialize(options); ConfigureTools(options); ConfigurePrompts(options); ConfigureResources(options); ConfigureLogging(options); ConfigureCompletion(options); ConfigureExperimentalAndExtensions(options); ConfigureTasks(options); ConfigureMrtr(); // Register any notification handlers that were provided. if (options.Handlers.NotificationHandlers is { } notificationHandlers) { _notificationHandlers.RegisterRange(notificationHandlers); } // In stateless mode, the server cannot send unsolicited notifications, // so listChanged should not be advertised. if (transport is StreamableHttpServerTransport { Stateless: true }) { if (ServerCapabilities.Tools is not null) ServerCapabilities.Tools.ListChanged = null; if (ServerCapabilities.Prompts is not null) ServerCapabilities.Prompts.ListChanged = null; if (ServerCapabilities.Resources is not null) ServerCapabilities.Resources.ListChanged = null; } // Now that everything has been configured, subscribe to any necessary notifications. if (transport is not StreamableHttpServerTransport streamableHttpTransport || streamableHttpTransport.Stateless is false) { Register(ServerOptions.ToolCollection, NotificationMethods.ToolListChangedNotification); Register(ServerOptions.PromptCollection, NotificationMethods.PromptListChangedNotification); Register(ServerOptions.ResourceCollection, NotificationMethods.ResourceListChangedNotification); void Register(McpServerPrimitiveCollection? collection, string notificationMethod) where TPrimitive : IMcpServerPrimitive { if (collection is not null) { EventHandler changed = (sender, e) => _ = this.SendNotificationAsync(notificationMethod); collection.Changed += changed; _disposables.Add(() => collection.Changed -= changed); } } } // And initialize the session. var incomingMessageFilter = BuildMessageFilterPipeline(options.Filters.Message.IncomingFilters); var outgoingMessageFilter = BuildMessageFilterPipeline(options.Filters.Message.OutgoingFilters); _sessionHandler = new McpSessionHandler( isServer: true, _sessionTransport, _endpointName!, _requestHandlers, _notificationHandlers, incomingMessageFilter, outgoingMessageFilter, _logger); } /// public override string? SessionId => _sessionTransport.SessionId; /// public override string? NegotiatedProtocolVersion => _negotiatedProtocolVersion; /// public ServerCapabilities ServerCapabilities { get; } /// public override ClientCapabilities? ClientCapabilities => _clientCapabilities; /// public override Implementation? ClientInfo => _clientInfo; /// public override McpServerOptions ServerOptions { get; } /// public override IServiceProvider? Services { get; } /// public override LoggingLevel? LoggingLevel => _loggingLevel?.Value; /// public override async Task RunAsync(CancellationToken cancellationToken = default) { if (Interlocked.Exchange(ref _started, 1) != 0) { throw new InvalidOperationException($"{nameof(RunAsync)} must only be called once."); } try { await _sessionHandler.ProcessMessagesAsync(cancellationToken).ConfigureAwait(false); } finally { await DisposeAsync().ConfigureAwait(false); } } /// public override Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default) => _sessionHandler.SendRequestAsync(request, cancellationToken); /// public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) => _sessionHandler.SendMessageAsync(message, cancellationToken); /// public override IAsyncDisposable RegisterNotificationHandler(string method, Func handler) => _sessionHandler.RegisterNotificationHandler(method, handler); /// public override async ValueTask DisposeAsync() { using var _ = await _disposeLock.LockAsync().ConfigureAwait(false); if (_disposed) { return; } _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 // can be created, so _mrtrContinuations is effectively frozen. _disposables.ForEach(d => d()); await _sessionHandler.DisposeAsync().ConfigureAwait(false); // Cancel all orphaned MRTR handlers still suspended in continuations (waiting for // retries that will never arrive now that the session handler is disposed). int cancelledCount = _mrtrContinuations.Count; foreach (var continuation in _mrtrContinuations.Values) { continuation.CancelHandler(); } if (cancelledCount > 0) { MrtrContinuationsCancelled(cancelledCount); } // Wait for all MRTR handler tasks to complete using the same inFlightCount + TCS // pattern as McpSessionHandler.ProcessMessagesCoreAsync. The count started at 1 // (for DisposeAsync itself); decrementing it here triggers the drain if handlers // are still in flight. ObserveHandlerCompletionAsync decrements for each handler. if (Interlocked.Decrement(ref _mrtrInFlightCount) != 0) { await _allMrtrHandlersCompleted.Task.ConfigureAwait(false); } } private void ConfigureInitialize(McpServerOptions options) { _requestHandlers.Set(RequestMethods.Initialize, async (request, _, _) => { _clientCapabilities = request?.Capabilities ?? new(); _clientInfo = request?.ClientInfo; // Use the ClientInfo to update the session EndpointName for logging. UpdateEndpointNameWithClientInfo(); _sessionHandler.EndpointName = _endpointName; // Negotiate a protocol version. If the server options provide one, use that. // Otherwise, try to use whatever the client requested as long as it's supported. // If it's not supported, fall back to the latest supported version. string? protocolVersion = options.ProtocolVersion; protocolVersion ??= request?.ProtocolVersion is string clientProtocolVersion && McpSessionHandler.SupportedProtocolVersions.Contains(clientProtocolVersion) ? clientProtocolVersion : McpSessionHandler.LatestProtocolVersion; _negotiatedProtocolVersion = protocolVersion; // Update session handler with the negotiated protocol version for telemetry _sessionHandler.NegotiatedProtocolVersion = protocolVersion; return new InitializeResult { ProtocolVersion = protocolVersion, Instructions = options.ServerInstructions, ServerInfo = options.ServerInfo ?? DefaultImplementation, Capabilities = ServerCapabilities ?? new(), }; }, McpJsonUtilities.JsonContext.Default.InitializeRequestParams, McpJsonUtilities.JsonContext.Default.InitializeResult); } private void ConfigureCompletion(McpServerOptions options) { var completeHandler = options.Handlers.CompleteHandler; var completionsCapability = options.Capabilities?.Completions; // Build completion value lookups from prompt/resource collections' [AllowedValues]-attributed parameters. Dictionary>? promptCompletions = BuildAllowedValueCompletions(options.PromptCollection); Dictionary>? resourceCompletions = BuildAllowedValueCompletions(options.ResourceCollection); bool hasCollectionCompletions = promptCompletions is not null || resourceCompletions is not null; if (completeHandler is null && completionsCapability is null && !hasCollectionCompletions) { return; } completeHandler ??= (static async (_, __) => new CompleteResult()); // Augment the completion handler with allowed values from prompt/resource collections. if (hasCollectionCompletions) { var originalCompleteHandler = completeHandler; completeHandler = async (request, cancellationToken) => { CompleteResult result = await originalCompleteHandler(request, cancellationToken).ConfigureAwait(false); string[]? allowedValues = null; switch (request.Params?.Ref) { case PromptReference pr when promptCompletions is not null: if (promptCompletions.TryGetValue(pr.Name, out var promptParams)) { promptParams.TryGetValue(request.Params.Argument.Name, out allowedValues); } break; case ResourceTemplateReference rtr when resourceCompletions is not null: if (rtr.Uri is not null && resourceCompletions.TryGetValue(rtr.Uri, out var resourceParams)) { resourceParams.TryGetValue(request.Params.Argument.Name, out allowedValues); } break; } if (allowedValues is not null) { string partialValue = request.Params!.Argument.Value; foreach (var v in allowedValues) { if (v.StartsWith(partialValue, StringComparison.OrdinalIgnoreCase)) { result.Completion.Values.Add(v); } } result.Completion.Total = result.Completion.Values.Count; } return result; }; } completeHandler = BuildFilterPipeline(completeHandler, options.Filters.Request.CompleteFilters); ServerCapabilities.Completions = new(); SetHandler( RequestMethods.CompletionComplete, completeHandler, McpJsonUtilities.JsonContext.Default.CompleteRequestParams, McpJsonUtilities.JsonContext.Default.CompleteResult); } /// /// Builds a lookup of primitive name/URI → (parameter name → allowed values) from the enum values /// in the JSON schemas of AIFunction-based prompts or resources. /// private static Dictionary>? BuildAllowedValueCompletions( McpServerPrimitiveCollection? primitives) where T : class, IMcpServerPrimitive { if (primitives is null) { return null; } Dictionary>? result = null; foreach (var primitive in primitives) { JsonElement schema; string id; if (primitive is AIFunctionMcpServerPrompt aiPrompt) { schema = aiPrompt.AIFunction.JsonSchema; id = aiPrompt.ProtocolPrompt.Name; } else if (primitive is AIFunctionMcpServerResource aiResource && aiResource.IsTemplated) { schema = aiResource.AIFunction.JsonSchema; id = aiResource.ProtocolResourceTemplate.UriTemplate; } else { continue; } if (schema.TryGetProperty("properties", out JsonElement properties) && properties.ValueKind is JsonValueKind.Object) { Dictionary? paramValues = null; foreach (var param in properties.EnumerateObject()) { if (param.Value.TryGetProperty("enum", out JsonElement enumValues) && enumValues.ValueKind is JsonValueKind.Array) { List? values = null; foreach (var item in enumValues.EnumerateArray()) { if (item.ValueKind is JsonValueKind.String && item.GetString() is { } str) { values ??= []; values.Add(str); } } if (values is not null) { paramValues ??= new(StringComparer.Ordinal); paramValues[param.Name] = [.. values]; } } } if (paramValues is not null) { result ??= new(StringComparer.Ordinal); result[id] = paramValues; } } } 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(); 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)); // 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); } private void ConfigureExperimentalAndExtensions(McpServerOptions options) { ServerCapabilities.Experimental = options.Capabilities?.Experimental; ServerCapabilities.Extensions = options.Capabilities?.Extensions; } private void ConfigureResources(McpServerOptions options) { var listResourcesHandler = options.Handlers.ListResourcesHandler; var listResourceTemplatesHandler = options.Handlers.ListResourceTemplatesHandler; var readResourceHandler = options.Handlers.ReadResourceHandler; var subscribeHandler = options.Handlers.SubscribeToResourcesHandler; var unsubscribeHandler = options.Handlers.UnsubscribeFromResourcesHandler; var resources = options.ResourceCollection; var resourcesCapability = options.Capabilities?.Resources; if (listResourcesHandler is null && listResourceTemplatesHandler is null && readResourceHandler is null && subscribeHandler is null && unsubscribeHandler is null && resources is null && resourcesCapability is null) { return; } ServerCapabilities.Resources = new(); listResourcesHandler ??= (static async (_, __) => new ListResourcesResult()); listResourceTemplatesHandler ??= (static async (_, __) => new ListResourceTemplatesResult()); readResourceHandler ??= (static async (request, _) => { var errorCode = McpHttpHeaders.UseInvalidParamsForMissingResource(request.Server.NegotiatedProtocolVersion) ? McpErrorCode.InvalidParams : McpErrorCode.ResourceNotFound; throw new McpProtocolException($"Unknown resource URI: '{request.Params?.Uri}'", errorCode); }); subscribeHandler ??= (static async (_, __) => new EmptyResult()); unsubscribeHandler ??= (static async (_, __) => new EmptyResult()); var listChanged = resourcesCapability?.ListChanged; var subscribe = resourcesCapability?.Subscribe; // Handle resources provided via DI. if (resources is not null) { var originalListResourcesHandler = listResourcesHandler; listResourcesHandler = async (request, cancellationToken) => { ListResourcesResult result = originalListResourcesHandler is not null ? await originalListResourcesHandler(request, cancellationToken).ConfigureAwait(false) : new(); if (request.Params?.Cursor is null) { foreach (var r in resources) { if (r.ProtocolResource is { } resource) { result.Resources.Add(resource); } } } return result; }; var originalListResourceTemplatesHandler = listResourceTemplatesHandler; listResourceTemplatesHandler = async (request, cancellationToken) => { ListResourceTemplatesResult result = originalListResourceTemplatesHandler is not null ? await originalListResourceTemplatesHandler(request, cancellationToken).ConfigureAwait(false) : new(); if (request.Params?.Cursor is null) { foreach (var rt in resources) { if (rt.IsTemplated) { result.ResourceTemplates.Add(rt.ProtocolResourceTemplate); } } } return result; }; // Synthesize read resource handler, which covers both resources and resource templates. var originalReadResourceHandler = readResourceHandler; readResourceHandler = async (request, cancellationToken) => { if (request.MatchedPrimitive is McpServerResource matchedResource) { return await matchedResource.ReadAsync(request, cancellationToken).ConfigureAwait(false); } return await originalReadResourceHandler(request, cancellationToken).ConfigureAwait(false); }; listChanged = true; // TODO: Implement subscribe/unsubscribe logic for resource and resource template collections. // subscribe = true; } listResourcesHandler = BuildFilterPipeline(listResourcesHandler, options.Filters.Request.ListResourcesFilters); listResourceTemplatesHandler = BuildFilterPipeline(listResourceTemplatesHandler, options.Filters.Request.ListResourceTemplatesFilters); readResourceHandler = BuildFilterPipeline(readResourceHandler, options.Filters.Request.ReadResourceFilters, handler => async (request, cancellationToken) => { // Initial handler that sets MatchedPrimitive if (request.Params?.Uri is { } uri && resources is not null) { // First try an O(1) lookup by exact match. if (resources.TryGetPrimitive(uri, out var resource) && !resource.IsTemplated) { request.MatchedPrimitive = resource; } else { // Fall back to an O(N) lookup, trying to match against each URI template. foreach (var resourceTemplate in resources) { if (resourceTemplate.IsMatch(uri)) { request.MatchedPrimitive = resourceTemplate; break; } } } } try { var result = await handler(request, cancellationToken).ConfigureAwait(false); ReadResourceCompleted(request.Params?.Uri ?? string.Empty); return result; } catch (Exception e) { ReadResourceError(request.Params?.Uri ?? string.Empty, e); throw; } }); subscribeHandler = BuildFilterPipeline(subscribeHandler, options.Filters.Request.SubscribeToResourcesFilters); unsubscribeHandler = BuildFilterPipeline(unsubscribeHandler, options.Filters.Request.UnsubscribeFromResourcesFilters); ServerCapabilities.Resources.ListChanged = listChanged; ServerCapabilities.Resources.Subscribe = subscribe; SetHandler( RequestMethods.ResourcesList, listResourcesHandler, McpJsonUtilities.JsonContext.Default.ListResourcesRequestParams, McpJsonUtilities.JsonContext.Default.ListResourcesResult); SetHandler( RequestMethods.ResourcesTemplatesList, listResourceTemplatesHandler, McpJsonUtilities.JsonContext.Default.ListResourceTemplatesRequestParams, McpJsonUtilities.JsonContext.Default.ListResourceTemplatesResult); SetHandler( RequestMethods.ResourcesRead, readResourceHandler, McpJsonUtilities.JsonContext.Default.ReadResourceRequestParams, McpJsonUtilities.JsonContext.Default.ReadResourceResult); SetHandler( RequestMethods.ResourcesSubscribe, subscribeHandler, McpJsonUtilities.JsonContext.Default.SubscribeRequestParams, McpJsonUtilities.JsonContext.Default.EmptyResult); SetHandler( RequestMethods.ResourcesUnsubscribe, unsubscribeHandler, McpJsonUtilities.JsonContext.Default.UnsubscribeRequestParams, McpJsonUtilities.JsonContext.Default.EmptyResult); } private void ConfigurePrompts(McpServerOptions options) { var listPromptsHandler = options.Handlers.ListPromptsHandler; var getPromptHandler = options.Handlers.GetPromptHandler; var prompts = options.PromptCollection; var promptsCapability = options.Capabilities?.Prompts; if (listPromptsHandler is null && getPromptHandler is null && prompts is null && promptsCapability is null) { return; } ServerCapabilities.Prompts = new(); listPromptsHandler ??= (static async (_, __) => new ListPromptsResult()); getPromptHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown prompt: '{request.Params?.Name}'", McpErrorCode.InvalidParams)); var listChanged = promptsCapability?.ListChanged; // Handle tools provided via DI by augmenting the handlers to incorporate them. if (prompts is not null) { var originalListPromptsHandler = listPromptsHandler; listPromptsHandler = async (request, cancellationToken) => { ListPromptsResult result = originalListPromptsHandler is not null ? await originalListPromptsHandler(request, cancellationToken).ConfigureAwait(false) : new(); if (request.Params?.Cursor is null) { foreach (var p in prompts) { result.Prompts.Add(p.ProtocolPrompt); } } return result; }; var originalGetPromptHandler = getPromptHandler; getPromptHandler = (request, cancellationToken) => { if (request.MatchedPrimitive is McpServerPrompt prompt) { return prompt.GetAsync(request, cancellationToken); } return originalGetPromptHandler(request, cancellationToken); }; listChanged = true; } listPromptsHandler = BuildFilterPipeline(listPromptsHandler, options.Filters.Request.ListPromptsFilters); getPromptHandler = BuildFilterPipeline(getPromptHandler, options.Filters.Request.GetPromptFilters, handler => async (request, cancellationToken) => { // Initial handler that sets MatchedPrimitive if (request.Params?.Name is { } promptName && prompts is not null && prompts.TryGetPrimitive(promptName, out var prompt)) { request.MatchedPrimitive = prompt; } try { var result = await handler(request, cancellationToken).ConfigureAwait(false); GetPromptCompleted(request.Params?.Name ?? string.Empty); return result; } catch (Exception e) { GetPromptError(request.Params?.Name ?? string.Empty, e); throw; } }); ServerCapabilities.Prompts.ListChanged = listChanged; SetHandler( RequestMethods.PromptsList, listPromptsHandler, McpJsonUtilities.JsonContext.Default.ListPromptsRequestParams, McpJsonUtilities.JsonContext.Default.ListPromptsResult); SetHandler( RequestMethods.PromptsGet, getPromptHandler, McpJsonUtilities.JsonContext.Default.GetPromptRequestParams, McpJsonUtilities.JsonContext.Default.GetPromptResult); } private void ConfigureTools(McpServerOptions options) { var listToolsHandler = options.Handlers.ListToolsHandler; var callToolHandler = options.Handlers.CallToolHandler; var callToolWithTaskHandler = options.Handlers.CallToolWithTaskHandler; var tools = options.ToolCollection; var toolsCapability = options.Capabilities?.Tools; if (listToolsHandler is null && callToolHandler is null && callToolWithTaskHandler is null && tools is null && toolsCapability is null) { return; } ServerCapabilities.Tools = new(); listToolsHandler ??= (static async (_, __) => new ListToolsResult()); var listChanged = toolsCapability?.ListChanged; var callToolFilters = options.Filters.Request.CallToolFilters; var callToolWithTaskFilters = options.Filters.Request.CallToolWithTaskFilters; // 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; if (hasNonTaskPath && hasTaskPath) { 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."); } // Handle tools provided via DI by augmenting the list handler. if (tools is not null) { var originalListToolsHandler = listToolsHandler; listToolsHandler = async (request, cancellationToken) => { ListToolsResult result = originalListToolsHandler is not null ? await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) : new(); if (request.Params?.Cursor is null) { foreach (var t in tools) { result.Tools.Add(t.ProtocolTool); } } return result; }; listChanged = true; } listToolsHandler = BuildFilterPipeline(listToolsHandler, options.Filters.Request.ListToolsFilters); // Build the unified task-augmented handler from one of the two paths. if (hasTaskPath) { // Case 2: task filter + task handler callToolWithTaskHandler ??= (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) => { if (request.MatchedPrimitive is McpServerTool tool) { return InvokeToolAsTask(tool, request, cancellationToken); } return originalHandler(request, cancellationToken); }; } callToolWithTaskHandler = BuildFilterPipeline(callToolWithTaskHandler, callToolWithTaskFilters, BuildInitialTaskToolFilter(tools)); } else { // Case 1: non-task filter + non-task handler → apply filters, then convert to task-based callToolHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams)); // Augment with DI tools. if (tools is not null) { var originalHandler = callToolHandler; callToolHandler = (request, cancellationToken) => { if (request.MatchedPrimitive is McpServerTool tool) { return tool.InvokeAsync(request, cancellationToken); } return originalHandler(request, cancellationToken); }; } callToolHandler = BuildFilterPipeline(callToolHandler, callToolFilters, BuildInitialCallToolFilter(tools)); // Convert to task-based. var finalCallToolHandler = callToolHandler; callToolWithTaskHandler = 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) => { if (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( RequestMethods.ToolsList, listToolsHandler, McpJsonUtilities.JsonContext.Default.ListToolsRequestParams, McpJsonUtilities.JsonContext.Default.ListToolsResult); SetTaskAugmentedHandler( RequestMethods.ToolsCall, callToolWithTaskHandler, McpJsonUtilities.JsonContext.Default.CallToolRequestParams, McpJsonUtilities.JsonContext.Default.CallToolResult, McpJsonUtilities.JsonContext.Default.CreateTaskResult); } private static CreateTaskResult ToCreateTaskResult(McpTaskInfo info) => new() { TaskId = info.TaskId, Status = info.Status, CreatedAt = info.CreatedAt, LastUpdatedAt = info.LastUpdatedAt, TtlMs = info.TtlMs, 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, TtlMs = info.TtlMs, PollIntervalMs = info.PollIntervalMs, StatusMessage = info.StatusMessage, ResultType = "complete", }, McpTaskStatus.Completed => new CompletedTaskResult { TaskId = info.TaskId, CreatedAt = info.CreatedAt, LastUpdatedAt = info.LastUpdatedAt, TtlMs = info.TtlMs, 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, TtlMs = info.TtlMs, 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, TtlMs = info.TtlMs, PollIntervalMs = info.PollIntervalMs, StatusMessage = info.StatusMessage, ResultType = "complete", }, McpTaskStatus.InputRequired => new InputRequiredTaskResult { TaskId = info.TaskId, CreatedAt = info.CreatedAt, LastUpdatedAt = info.LastUpdatedAt, TtlMs = info.TtlMs, 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( McpServerTool tool, RequestContext request, CancellationToken cancellationToken) { return await tool.InvokeAsync(request, cancellationToken).ConfigureAwait(false); } private McpRequestFilter BuildInitialCallToolFilter( McpServerPrimitiveCollection? tools) => handler => async (request, cancellationToken) => { if (request.Params?.Name is { } toolName && tools is not null && tools.TryGetPrimitive(toolName, out var tool)) { request.MatchedPrimitive = tool; } try { var result = await handler(request, cancellationToken).ConfigureAwait(false); ToolCallCompleted(request.Params?.Name ?? string.Empty, result.IsError is true); return result; } catch (Exception e) { // Skip logging for InputRequiredException - it's normal MRTR control flow, // not an error (tools throw it to signal an InputRequiredResult). if (!(e is OperationCanceledException && cancellationToken.IsCancellationRequested) && e is not InputRequiredException) { ToolCallError(request.Params?.Name ?? string.Empty, e); } if ((e is OperationCanceledException && cancellationToken.IsCancellationRequested) || e is McpProtocolException || e is InputRequiredException) { throw; } return new() { IsError = true, Content = [new TextContentBlock { Text = e is McpException ? $"An error occurred invoking '{request.Params?.Name}': {e.Message}" : $"An error occurred invoking '{request.Params?.Name}'.", }], }; } }; private McpRequestFilter> BuildInitialTaskToolFilter( McpServerPrimitiveCollection? tools) => handler => async (request, cancellationToken) => { if (request.Params?.Name is { } toolName && tools is not null && tools.TryGetPrimitive(toolName, out var tool)) { request.MatchedPrimitive = tool; } try { var result = await handler(request, cancellationToken).ConfigureAwait(false); if (!result.IsTask) { ToolCallCompleted(request.Params?.Name ?? string.Empty, result.Result!.IsError is true); } return result; } catch (Exception e) { // Skip logging for InputRequiredException - it's normal MRTR control flow, // not an error (tools throw it to signal an InputRequiredResult). if (!(e is OperationCanceledException && cancellationToken.IsCancellationRequested) && e is not InputRequiredException) { ToolCallError(request.Params?.Name ?? string.Empty, e); } if ((e is OperationCanceledException && cancellationToken.IsCancellationRequested) || e is McpProtocolException || e is InputRequiredException) { throw; } return new CallToolResult { IsError = true, Content = [new TextContentBlock { Text = e is McpException ? $"An error occurred invoking '{request.Params?.Name}': {e.Message}" : $"An error occurred invoking '{request.Params?.Name}'.", }], }; } }; private void ConfigureLogging(McpServerOptions options) { // We don't require that the handler be provided, as we always store the provided log level to the server. var setLoggingLevelHandler = options.Handlers.SetLoggingLevelHandler; // Apply filters to the handler if (setLoggingLevelHandler is not null) { setLoggingLevelHandler = BuildFilterPipeline(setLoggingLevelHandler, options.Filters.Request.SetLoggingLevelFilters); } ServerCapabilities.Logging = new(); _requestHandlers.Set( RequestMethods.LoggingSetLevel, (request, jsonRpcRequest, cancellationToken) => { // Store the provided level. if (request is not null) { if (_loggingLevel is null) { Interlocked.CompareExchange(ref _loggingLevel, new(request.Level), null); } _loggingLevel.Value = request.Level; } // If a handler was provided, now delegate to it. if (setLoggingLevelHandler is not null) { return InvokeHandlerAsync(setLoggingLevelHandler, request!, jsonRpcRequest, cancellationToken); } // Otherwise, consider it handled. return new ValueTask(EmptyResult.Instance); }, McpJsonUtilities.JsonContext.Default.SetLevelRequestParams, McpJsonUtilities.JsonContext.Default.EmptyResult); } private ValueTask InvokeHandlerAsync( McpRequestHandler handler, TParams args, JsonRpcRequest jsonRpcRequest, CancellationToken cancellationToken = default) { return _servicesScopePerRequest ? InvokeScopedAsync(handler, args, jsonRpcRequest, cancellationToken) : handler(new(CreateDestinationBoundServer(jsonRpcRequest), jsonRpcRequest, args), cancellationToken); async ValueTask InvokeScopedAsync( McpRequestHandler handler, TParams args, JsonRpcRequest jsonRpcRequest, CancellationToken cancellationToken) { var scope = Services?.GetService()?.CreateAsyncScope(); try { return await handler( new RequestContext(CreateDestinationBoundServer(jsonRpcRequest), jsonRpcRequest, args) { Services = scope?.ServiceProvider ?? Services, }, cancellationToken).ConfigureAwait(false); } finally { if (scope is not null) { await scope.Value.DisposeAsync().ConfigureAwait(false); } } } } private DestinationBoundMcpServer CreateDestinationBoundServer(JsonRpcRequest jsonRpcRequest) { var server = new DestinationBoundMcpServer(this, jsonRpcRequest.Context?.RelatedTransport); if (_mrtrContextsByRequestId.TryRemove(jsonRpcRequest.Id, out var mrtrContext)) { server.ActiveMrtrContext = mrtrContext; } return server; } private void SetHandler( string method, McpRequestHandler handler, JsonTypeInfo requestTypeInfo, JsonTypeInfo responseTypeInfo) { // SEP-2549: results that carry caching hints (tools/list, prompts/list, resources/list, // resources/templates/list, and resources/read) declare ttlMs and cacheScope as required fields. // When a handler leaves them unset, fill in conservative defaults (immediately stale and not // shareable) so the wire form always carries the fields while preserving today's "don't cache" // behavior. Any value supplied by the handler or a filter is left untouched. if (typeof(ICacheableResult).IsAssignableFrom(typeof(TResult))) { var innerHandler = handler; handler = async (request, cancellationToken) => { var result = await innerHandler(request, cancellationToken).ConfigureAwait(false); if (result is ICacheableResult cacheable) { cacheable.TimeToLive ??= TimeSpan.Zero; cacheable.CacheScope ??= CacheScope.Private; } return result; }; } _requestHandlers.Set(method, (request, jsonRpcRequest, cancellationToken) => InvokeHandlerAsync(handler, request, jsonRpcRequest, cancellationToken), requestTypeInfo, responseTypeInfo); } private void SetTaskAugmentedHandler( string method, McpRequestHandler> handler, JsonTypeInfo requestTypeInfo, JsonTypeInfo responseTypeInfo, JsonTypeInfo taskResultTypeInfo) where TResult : Result { _requestHandlers.SetTaskAugmented(method, (request, jsonRpcRequest, cancellationToken) => InvokeHandlerAsync(handler, request, jsonRpcRequest, cancellationToken), requestTypeInfo, responseTypeInfo, taskResultTypeInfo); } private static McpRequestHandler BuildFilterPipeline( McpRequestHandler baseHandler, IList> filters, McpRequestFilter? initialHandler = null) { var current = baseHandler; for (int i = filters.Count - 1; i >= 0; i--) { current = filters[i](current); } if (initialHandler is not null) { current = initialHandler(current); } 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 = {} // TODO: swap the literals for a shared NotificationMethods.ClientCapabilitiesMetaKey once // the SEP-2575 plumbing lands. private const string ClientCapabilitiesMetaKey = "io.modelcontextprotocol/clientCapabilities"; private const string ExtensionsKey = "extensions"; private static bool HasTaskExtensionOptIn(JsonObject? meta) => meta is not null && meta[ClientCapabilitiesMetaKey] is JsonObject caps && caps[ExtensionsKey] is JsonObject exts && exts.ContainsKey(McpExtensions.Tasks); private JsonRpcMessageFilter BuildMessageFilterPipeline(IList filters) { if (filters.Count == 0) { return next => next; } return next => { // Build the handler chain from the filters. // The innermost handler calls the provided 'next' delegate with the message from the context. McpMessageHandler baseHandler = async (context, cancellationToken) => { await next(context.JsonRpcMessage, cancellationToken).ConfigureAwait(false); }; var current = baseHandler; for (int i = filters.Count - 1; i >= 0; i--) { current = filters[i](current); } // Return the handler that creates a MessageContext and invokes the pipeline. return async (message, cancellationToken) => { // Ensure message has a Context so Items can be shared through the pipeline message.Context ??= new(); var context = new MessageContext(new DestinationBoundMcpServer(this, message.Context.RelatedTransport), message); await current(context, cancellationToken).ConfigureAwait(false); }; }; } private void UpdateEndpointNameWithClientInfo() { if (ClientInfo is null) { return; } _endpointName = $"{_serverOnlyEndpointName}, Client ({ClientInfo.Name} {ClientInfo.Version})"; } /// Maps a to a . internal static LoggingLevel ToLoggingLevel(LogLevel level) => level switch { LogLevel.Trace => Protocol.LoggingLevel.Debug, LogLevel.Debug => Protocol.LoggingLevel.Debug, LogLevel.Information => Protocol.LoggingLevel.Info, LogLevel.Warning => Protocol.LoggingLevel.Warning, LogLevel.Error => Protocol.LoggingLevel.Error, LogLevel.Critical => Protocol.LoggingLevel.Critical, _ => Protocol.LoggingLevel.Emergency, }; /// /// Checks whether the negotiated protocol version enables MRTR per SEP-2322 (DRAFT-2026-v1). /// internal bool ClientSupportsMrtr() => _negotiatedProtocolVersion == McpSessionHandler.DraftProtocolVersion; /// /// Returns when the session is stateful - the same server instance handles /// subsequent requests on the same session. The legacy backcompat resolver in /// needs a stateful session so it can send /// elicitation/create / sampling/createMessage / roots/list to the client and /// retry the handler with the responses. /// internal bool IsStatefulSession() => _sessionTransport is not StreamableHttpServerTransport { Stateless: true }; /// public override bool IsMrtrSupported => ClientSupportsMrtr() || IsStatefulSession(); /// /// Invokes a handler and catches to convert it to an /// JSON response. When MRTR is negotiated or the server is stateless, /// the result is serialized directly. Otherwise, input requests are resolved via standard JSON-RPC /// calls (elicitation, sampling, roots) and the handler is retried with the responses - allowing /// MRTR-native tools to work transparently with clients that don't support MRTR. /// private async Task InvokeWithInputRequiredResultHandlingAsync( Func> handler, JsonRpcRequest request, CancellationToken cancellationToken) { const int MaxRetries = 10; // In stateless mode, pick up the negotiated draft protocol version from the // transport-provided request context because there is no long-lived initialize handshake state. if (_negotiatedProtocolVersion is null && request.Context?.ProtocolVersion is { } headerProtocolVersion) { _negotiatedProtocolVersion = headerProtocolVersion; } for (int retry = 0; ; retry++) { try { return await handler(request, cancellationToken).ConfigureAwait(false); } 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. // TODO(stateless-draft): When DRAFT-2026-v1 becomes stateless-only, the IsStatefulSession() gate collapses - the stateful path will only matter for legacy clients on the current protocol. if (!IsStatefulSession()) { 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)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, }; } } } /// /// Resolves a batch of MRTR input requests concurrently by dispatching each as a standard /// JSON-RPC request to the client. The requests are routed via /// so they go out through the POST's response stream (matching the behavior of tool-initiated /// server-to-client requests like server.SampleAsync) and avoid racing with the client's /// GET stream startup. On the first failure all remaining handlers are cancelled so user-facing /// flows (sampling/elicitation prompts) don't keep running once the caller has given up, and /// exceptions from late-completing tasks are observed before the original exception is rethrown. /// private static async Task> ResolveInputRequestsAsync( McpServer destinationServer, IDictionary inputRequests, CancellationToken cancellationToken) { using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); var keyed = new (string Key, Task Task)[inputRequests.Count]; int i = 0; foreach (var kvp in inputRequests) { keyed[i++] = (kvp.Key, ResolveInputRequestAsync(destinationServer, kvp.Value, linkedCts.Token)); } try { await Task.WhenAll(Array.ConvertAll(keyed, k => k.Task)).ConfigureAwait(false); } catch { linkedCts.Cancel(); try { await Task.WhenAll(Array.ConvertAll(keyed, k => k.Task)).ConfigureAwait(false); } catch { // Observed; the original exception is the one we want to surface. } throw; } var responses = new Dictionary(keyed.Length); foreach (var (key, task) in keyed) { responses[key] = task.Result; } return responses; } /// /// Resolves a single MRTR by dispatching it as a standard JSON-RPC /// request to the client via . This is the server-side mirror /// of the client's input resolution logic, used for backward compatibility when the client doesn't /// support MRTR. /// private static async Task ResolveInputRequestAsync(McpServer destinationServer, InputRequest inputRequest, CancellationToken cancellationToken) { switch (inputRequest.Method) { case RequestMethods.ElicitationCreate: var elicitParams = inputRequest.ElicitationParams ?? throw new McpException("Failed to deserialize elicitation parameters from MRTR input request."); var elicitResult = await destinationServer.ElicitAsync(elicitParams, cancellationToken).ConfigureAwait(false); return InputResponse.FromElicitResult(elicitResult); case RequestMethods.SamplingCreateMessage: var samplingParams = inputRequest.SamplingParams ?? throw new McpException("Failed to deserialize sampling parameters from MRTR input request."); var samplingResult = await destinationServer.SampleAsync(samplingParams, cancellationToken).ConfigureAwait(false); return InputResponse.FromSamplingResult(samplingResult); case RequestMethods.RootsList: var rootsParams = inputRequest.RootsParams ?? new ListRootsRequestParams(); var rootsResult = await destinationServer.RequestRootsAsync(rootsParams, cancellationToken).ConfigureAwait(false); return InputResponse.FromRootsResult(rootsResult); default: throw new McpException($"Unsupported input request method: '{inputRequest.Method}'."); } } private static JsonNode? SerializeInputRequiredResult(InputRequiredResult inputRequiredResult) => JsonSerializer.SerializeToNode(inputRequiredResult, McpJsonUtilities.JsonContext.Default.InputRequiredResult); /// /// Wraps MRTR-eligible request handlers so that when a handler calls ElicitAsync/SampleAsync/RequestRootsAsync, /// an is returned early and the handler is suspended until the retry arrives. /// private void ConfigureMrtr() { // Wrap all methods that may trigger MRTR (server calling ElicitAsync/SampleAsync/RequestRootsAsync // during handler execution). These methods may produce InputRequiredResult if the handler needs input. WrapHandlerWithMrtr(RequestMethods.ToolsCall); WrapHandlerWithMrtr(RequestMethods.PromptsGet); WrapHandlerWithMrtr(RequestMethods.ResourcesRead); } /// /// Replaces an existing request handler entry with an MRTR-aware wrapper that supports /// handler suspension and responses. /// private void WrapHandlerWithMrtr(string method) { if (!_requestHandlers.TryGetValue(method, out var originalHandler)) { return; } _requestHandlers[method] = async (request, cancellationToken) => { // In stateless mode, each request creates a new server instance that never saw the // initialize handshake, so _negotiatedProtocolVersion is null. Pick it up from the // Mcp-Protocol-Version header that the transport layer flowed via JsonRpcMessageContext. if (_negotiatedProtocolVersion is null && request.Context?.ProtocolVersion is { } headerProtocolVersion) { _negotiatedProtocolVersion = headerProtocolVersion; } // Check for MRTR retry: if requestState is present, look up the continuation. if (request.Params is JsonObject paramsObj && paramsObj.TryGetPropertyValue("requestState", out var requestStateNode) && requestStateNode?.GetValueKind() == JsonValueKind.String && requestStateNode.GetValue() is { } requestState) { if (_mrtrContinuations.TryRemove(requestState, out var existingContinuation)) { // Implicit MRTR retry: resume the suspended handler with client responses. IDictionary? inputResponses = null; if (paramsObj.TryGetPropertyValue("inputResponses", out var responsesNode) && responsesNode is not null) { inputResponses = JsonSerializer.Deserialize(responsesNode, McpJsonUtilities.JsonContext.Default.IDictionaryStringInputResponse); } var exchange = existingContinuation.PendingExchange!; var nextExchangeTask = existingContinuation.MrtrContext.ResetForNextExchange(exchange); if (inputResponses is not null && inputResponses.TryGetValue(exchange.Key, out var response)) { if (!exchange.ResponseTcs.TrySetResult(response)) { throw new McpProtocolException( $"MRTR exchange '{exchange.Key}' was already completed (possibly cancelled).", McpErrorCode.InternalError); } } else { if (!exchange.ResponseTcs.TrySetException( new McpProtocolException($"Missing input response for key '{exchange.Key}'.", McpErrorCode.InvalidParams))) { throw new McpProtocolException( $"MRTR exchange '{exchange.Key}' was already completed (possibly cancelled).", McpErrorCode.InternalError); } } return await AwaitMrtrHandlerAsync( existingContinuation.HandlerTask, existingContinuation, nextExchangeTask, cancellationToken).ConfigureAwait(false); } // Explicit MRTR retry or invalid requestState: no continuation found. // Fall through to the standard MRTR-aware invocation path below. The retry data // (inputResponses, requestState) is already in the deserialized request params // for low-level handlers to access, and the MrtrContext will be set up for // high-level handlers that call ElicitAsync/SampleAsync. } // Implicit MRTR (handler suspension across ElicitAsync/SampleAsync) emits // InputRequiredResult on the wire, which only DRAFT-2026-v1 clients understand, // and requires the same server instance to handle the retry (stateful session). // For all other cases - legacy clients, stateless sessions - fall through to the // exception-based path, which transparently resolves InputRequiredException via // legacy JSON-RPC requests when the client doesn't speak MRTR. if (!ClientSupportsMrtr() || !IsStatefulSession()) { return await InvokeWithInputRequiredResultHandlingAsync(originalHandler, request, cancellationToken).ConfigureAwait(false); } // Start a new MRTR-aware handler invocation. var mrtrContext = new MrtrContext(); // Create a long-lived CTS for the handler that survives across retries. // The original request's combinedCts will be disposed when this lambda returns, // breaking the cancellation chain. This CTS keeps the handler cancellable. // Like Kestrel's HttpContext.RequestAborted, the CTS is never disposed - Cancel() // is thread-safe with itself, and not disposing avoids deadlock risks from // calling Cancel/Dispose inside locks or Interlocked guards. var handlerCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); // Store the MrtrContext so CreateDestinationBoundServer can pick it up and set it // on the per-request DestinationBoundMcpServer. This is picked up synchronously // before any await, so the finally cleanup is safe. _mrtrContextsByRequestId[request.Id] = mrtrContext; Task handlerTask; try { handlerTask = originalHandler(request, handlerCts.Token); } finally { _mrtrContextsByRequestId.TryRemove(request.Id, out _); } // Wrap handler state into a continuation for lifecycle management across retries. var continuation = new MrtrContinuation(handlerCts, handlerTask, mrtrContext); // Track the handler task for lifecycle management. The observer logs unhandled // exceptions and decrements _mrtrInFlightCount when the handler completes, // mirroring how McpSessionHandler tracks in-flight handlers. Interlocked.Increment(ref _mrtrInFlightCount); _ = ObserveHandlerCompletionAsync(handlerTask); return await AwaitMrtrHandlerAsync( handlerTask, continuation, mrtrContext.InitialExchangeTask, cancellationToken).ConfigureAwait(false); }; } /// /// Awaits the outcome of an MRTR-enabled handler invocation. /// If the handler completes, returns its result. If an exchange arrives (handler needs input), /// builds and returns an and stores the continuation for future retries. /// If the handler throws , the result is returned directly /// without storing a continuation (explicit MRTR path). /// private async Task AwaitMrtrHandlerAsync( Task handlerTask, MrtrContinuation continuation, Task exchangeTask, CancellationToken cancellationToken) { // Link the current request's cancellation to the handler's long-lived CTS. // On the initial call this is redundant (handlerCts is already linked to cancellationToken) // but on retries this is critical: the retry's combinedCts cancellation must flow to the handler. // This is how notifications/cancelled for the retry's request ID reaches the handler. using var registration = cancellationToken.Register( static state => ((MrtrContinuation)state!).CancelHandler(), continuation); // Race handler against MRTR exchange. var completedTask = await Task.WhenAny(handlerTask, exchangeTask).ConfigureAwait(false); if (completedTask == handlerTask) { // Handler completed - return its result, propagate its exception, or handle InputRequiredException. return await AwaitHandlerWithInputRequiredResultHandlingAsync(handlerTask).ConfigureAwait(false); } // Exchange arrived - handler needs input from the client (implicit MRTR path). var exchange = await exchangeTask.ConfigureAwait(false); var correlationId = Guid.NewGuid().ToString("N"); var inputRequiredResult = new InputRequiredResult { InputRequests = new Dictionary { [exchange.Key] = exchange.InputRequest }, RequestState = correlationId, }; // Store the continuation so the retry can resume the handler. continuation.PendingExchange = exchange; _mrtrContinuations[correlationId] = continuation; return SerializeInputRequiredResult(inputRequiredResult); } /// /// Fire-and-forget observer for an MRTR handler task. Logs unhandled exceptions at Debug /// level (the same exception still propagates to the request pipeline, so Debug avoids /// double-reporting at Error) and decrements when the /// handler completes, following the same in-flight tracking pattern as . /// private async Task ObserveHandlerCompletionAsync(Task handlerTask) { try { await handlerTask.ConfigureAwait(false); } catch (OperationCanceledException) { // Handler cancelled - expected lifecycle event (disposal, client cancel, session shutdown). } catch (InputRequiredException) { // Explicit MRTR: handler explicitly signaling an InputRequiredResult. Not an error. } catch (Exception ex) { MrtrHandlerError(ex); } finally { if (Interlocked.Decrement(ref _mrtrInFlightCount) == 0) { _allMrtrHandlersCompleted.TrySetResult(true); } } } /// /// Awaits a handler task, catching to convert it to an /// JSON response without storing a continuation. /// private static async Task AwaitHandlerWithInputRequiredResultHandlingAsync(Task handlerTask) { try { return await handlerTask.ConfigureAwait(false); } catch (InputRequiredException ex) { return SerializeInputRequiredResult(ex.Result); } } [LoggerMessage(Level = LogLevel.Error, Message = "\"{ToolName}\" threw an unhandled exception.")] private partial void ToolCallError(string toolName, Exception exception); [LoggerMessage(Level = LogLevel.Information, Message = "\"{ToolName}\" completed. IsError = {IsError}.")] private partial void ToolCallCompleted(string toolName, bool isError); [LoggerMessage(Level = LogLevel.Error, Message = "GetPrompt \"{PromptName}\" threw an unhandled exception.")] private partial void GetPromptError(string promptName, Exception exception); [LoggerMessage(Level = LogLevel.Information, Message = "GetPrompt \"{PromptName}\" completed.")] private partial void GetPromptCompleted(string promptName); [LoggerMessage(Level = LogLevel.Error, Message = "ReadResource \"{ResourceUri}\" threw an unhandled exception.")] private partial void ReadResourceError(string resourceUri, Exception exception); [LoggerMessage(Level = LogLevel.Information, Message = "ReadResource \"{ResourceUri}\" completed.")] private partial void ReadResourceCompleted(string resourceUri); [LoggerMessage(Level = LogLevel.Debug, Message = "Cancelled {Count} pending MRTR continuation(s) during session disposal.")] private partial void MrtrContinuationsCancelled(int count); [LoggerMessage(Level = LogLevel.Debug, Message = "An MRTR handler threw an unhandled exception.")] private partial void MrtrHandlerError(Exception exception); }