1862 lines
85 KiB
C#
1862 lines
85 KiB
C#
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;
|
|
|
|
/// <inheritdoc />
|
|
#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<Action> _disposables = [];
|
|
private readonly NotificationHandlers _notificationHandlers;
|
|
private readonly RequestHandlers _requestHandlers;
|
|
private readonly McpSessionHandler _sessionHandler;
|
|
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();
|
|
|
|
// 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<bool> _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;
|
|
|
|
/// <summary>Holds a boxed <see cref="LoggingLevel"/> value for the server.</summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
private StrongBox<LoggingLevel>? _loggingLevel;
|
|
|
|
/// <summary>
|
|
/// Creates a new instance of <see cref="McpServerImpl"/>.
|
|
/// </summary>
|
|
/// <param name="transport">Transport to use for the server representing an already-established session.</param>
|
|
/// <param name="options">Configuration options for this server, including capabilities.
|
|
/// Make sure to accurately reflect exactly what capabilities the server supports and does not support.</param>
|
|
/// <param name="loggerFactory">Logger factory to use for logging</param>
|
|
/// <param name="serviceProvider">Optional service provider to use for dependency injection</param>
|
|
/// <exception cref="McpException">The server was incorrectly configured.</exception>
|
|
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<McpServer>() ?? NullLogger<McpServer>.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<TPrimitive>(McpServerPrimitiveCollection<TPrimitive>? 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);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public override string? SessionId => _sessionTransport.SessionId;
|
|
|
|
/// <inheritdoc/>
|
|
public override string? NegotiatedProtocolVersion => _negotiatedProtocolVersion;
|
|
|
|
/// <inheritdoc/>
|
|
public ServerCapabilities ServerCapabilities { get; }
|
|
|
|
/// <inheritdoc />
|
|
public override ClientCapabilities? ClientCapabilities => _clientCapabilities;
|
|
|
|
/// <inheritdoc />
|
|
public override Implementation? ClientInfo => _clientInfo;
|
|
|
|
/// <inheritdoc />
|
|
public override McpServerOptions ServerOptions { get; }
|
|
|
|
/// <inheritdoc />
|
|
public override IServiceProvider? Services { get; }
|
|
|
|
/// <inheritdoc />
|
|
public override LoggingLevel? LoggingLevel => _loggingLevel?.Value;
|
|
|
|
/// <inheritdoc />
|
|
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);
|
|
}
|
|
}
|
|
|
|
|
|
/// <inheritdoc/>
|
|
public override Task<JsonRpcResponse> SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken = default)
|
|
=> _sessionHandler.SendRequestAsync(request, cancellationToken);
|
|
|
|
/// <inheritdoc/>
|
|
public override Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
|
|
=> _sessionHandler.SendMessageAsync(message, cancellationToken);
|
|
|
|
/// <inheritdoc/>
|
|
public override IAsyncDisposable RegisterNotificationHandler(string method, Func<JsonRpcNotification, CancellationToken, ValueTask> handler)
|
|
=> _sessionHandler.RegisterNotificationHandler(method, handler);
|
|
|
|
/// <inheritdoc/>
|
|
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<string, Dictionary<string, string[]>>? promptCompletions = BuildAllowedValueCompletions(options.PromptCollection);
|
|
Dictionary<string, Dictionary<string, string[]>>? 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static Dictionary<string, Dictionary<string, string[]>>? BuildAllowedValueCompletions<T>(
|
|
McpServerPrimitiveCollection<T>? primitives) where T : class, IMcpServerPrimitive
|
|
{
|
|
if (primitives is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
Dictionary<string, Dictionary<string, string[]>>? 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<string, string[]>? paramValues = null;
|
|
foreach (var param in properties.EnumerateObject())
|
|
{
|
|
if (param.Value.TryGetProperty("enum", out JsonElement enumValues) &&
|
|
enumValues.ValueKind is JsonValueKind.Array)
|
|
{
|
|
List<string>? 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<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));
|
|
|
|
// 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);
|
|
}
|
|
|
|
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<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(
|
|
McpServerTool tool,
|
|
RequestContext<CallToolRequestParams> request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return await tool.InvokeAsync(request, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
private McpRequestFilter<CallToolRequestParams, CallToolResult> BuildInitialCallToolFilter(
|
|
McpServerPrimitiveCollection<McpServerTool>? 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<CallToolRequestParams, ResultOrCreatedTask<CallToolResult>> BuildInitialTaskToolFilter(
|
|
McpServerPrimitiveCollection<McpServerTool>? 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>(EmptyResult.Instance);
|
|
},
|
|
McpJsonUtilities.JsonContext.Default.SetLevelRequestParams,
|
|
McpJsonUtilities.JsonContext.Default.EmptyResult);
|
|
}
|
|
|
|
private ValueTask<TResult> InvokeHandlerAsync<TParams, TResult>(
|
|
McpRequestHandler<TParams, TResult> handler,
|
|
TParams args,
|
|
JsonRpcRequest jsonRpcRequest,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
return _servicesScopePerRequest ?
|
|
InvokeScopedAsync(handler, args, jsonRpcRequest, cancellationToken) :
|
|
handler(new(CreateDestinationBoundServer(jsonRpcRequest), jsonRpcRequest, args), cancellationToken);
|
|
|
|
async ValueTask<TResult> InvokeScopedAsync(
|
|
McpRequestHandler<TParams, TResult> handler,
|
|
TParams args,
|
|
JsonRpcRequest jsonRpcRequest,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var scope = Services?.GetService<IServiceScopeFactory>()?.CreateAsyncScope();
|
|
try
|
|
{
|
|
return await handler(
|
|
new RequestContext<TParams>(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<TParams, TResult>(
|
|
string method,
|
|
McpRequestHandler<TParams, TResult> handler,
|
|
JsonTypeInfo<TParams> requestTypeInfo,
|
|
JsonTypeInfo<TResult> 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<TParams, TResult>(
|
|
string method,
|
|
McpRequestHandler<TParams, ResultOrCreatedTask<TResult>> handler,
|
|
JsonTypeInfo<TParams> requestTypeInfo,
|
|
JsonTypeInfo<TResult> responseTypeInfo,
|
|
JsonTypeInfo<CreateTaskResult> taskResultTypeInfo)
|
|
where TResult : Result
|
|
{
|
|
_requestHandlers.SetTaskAugmented(method,
|
|
(request, jsonRpcRequest, cancellationToken) =>
|
|
InvokeHandlerAsync(handler, request, jsonRpcRequest, cancellationToken),
|
|
requestTypeInfo, responseTypeInfo, taskResultTypeInfo);
|
|
}
|
|
|
|
private static McpRequestHandler<TParams, TResult> BuildFilterPipeline<TParams, TResult>(
|
|
McpRequestHandler<TParams, TResult> baseHandler,
|
|
IList<McpRequestFilter<TParams, TResult>> filters,
|
|
McpRequestFilter<TParams, TResult>? 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<McpMessageFilter> 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})";
|
|
}
|
|
|
|
/// <summary>Maps a <see cref="LogLevel"/> to a <see cref="LoggingLevel"/>.</summary>
|
|
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,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Checks whether the negotiated protocol version enables MRTR per SEP-2322 (DRAFT-2026-v1).
|
|
/// </summary>
|
|
internal bool ClientSupportsMrtr() =>
|
|
_negotiatedProtocolVersion == McpSessionHandler.DraftProtocolVersion;
|
|
|
|
/// <summary>
|
|
/// Returns <see langword="true"/> when the session is stateful - the same server instance handles
|
|
/// subsequent requests on the same session. The legacy backcompat resolver in
|
|
/// <see cref="InvokeWithInputRequiredResultHandlingAsync"/> needs a stateful session so it can send
|
|
/// <c>elicitation/create</c> / <c>sampling/createMessage</c> / <c>roots/list</c> to the client and
|
|
/// retry the handler with the responses.
|
|
/// </summary>
|
|
internal bool IsStatefulSession() =>
|
|
_sessionTransport is not StreamableHttpServerTransport { Stateless: true };
|
|
|
|
/// <inheritdoc />
|
|
public override bool IsMrtrSupported => ClientSupportsMrtr() || IsStatefulSession();
|
|
|
|
/// <summary>
|
|
/// Invokes a handler and catches <see cref="InputRequiredException"/> to convert it to an
|
|
/// <see cref="InputRequiredResult"/> 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.
|
|
/// </summary>
|
|
private async Task<JsonNode?> InvokeWithInputRequiredResultHandlingAsync(
|
|
Func<JsonRpcRequest, CancellationToken, Task<JsonNode?>> 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<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,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <paramref name="destinationServer"/>
|
|
/// so they go out through the POST's response stream (matching the behavior of tool-initiated
|
|
/// server-to-client requests like <c>server.SampleAsync</c>) 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.
|
|
/// </summary>
|
|
private static async Task<IDictionary<string, InputResponse>> ResolveInputRequestsAsync(
|
|
McpServer destinationServer,
|
|
IDictionary<string, InputRequest> inputRequests,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
|
|
var keyed = new (string Key, Task<InputResponse> 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<string, InputResponse>(keyed.Length);
|
|
foreach (var (key, task) in keyed)
|
|
{
|
|
responses[key] = task.Result;
|
|
}
|
|
return responses;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves a single MRTR <see cref="InputRequest"/> by dispatching it as a standard JSON-RPC
|
|
/// request to the client via <paramref name="destinationServer"/>. This is the server-side mirror
|
|
/// of the client's input resolution logic, used for backward compatibility when the client doesn't
|
|
/// support MRTR.
|
|
/// </summary>
|
|
private static async Task<InputResponse> 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);
|
|
|
|
/// <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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Replaces an existing request handler entry with an MRTR-aware wrapper that supports
|
|
/// handler suspension and <see cref="InputRequiredResult"/> responses.
|
|
/// </summary>
|
|
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<string>() is { } requestState)
|
|
{
|
|
if (_mrtrContinuations.TryRemove(requestState, out var existingContinuation))
|
|
{
|
|
// Implicit MRTR retry: resume the suspended handler with client responses.
|
|
IDictionary<string, InputResponse>? 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<JsonNode?> 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);
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="InputRequiredResult"/> and stores the continuation for future retries.
|
|
/// If the handler throws <see cref="InputRequiredException"/>, the result is returned directly
|
|
/// without storing a continuation (explicit MRTR path).
|
|
/// </summary>
|
|
private async Task<JsonNode?> AwaitMrtrHandlerAsync(
|
|
Task<JsonNode?> handlerTask,
|
|
MrtrContinuation continuation,
|
|
Task<MrtrExchange> 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<string, InputRequest> { [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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="_mrtrInFlightCount"/> when the
|
|
/// handler completes, following the same in-flight tracking pattern as <see cref="McpSessionHandler"/>.
|
|
/// </summary>
|
|
private async Task ObserveHandlerCompletionAsync(Task<JsonNode?> 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Awaits a handler task, catching <see cref="InputRequiredException"/> to convert it to an
|
|
/// <see cref="InputRequiredResult"/> JSON response without storing a continuation.
|
|
/// </summary>
|
|
private static async Task<JsonNode?> AwaitHandlerWithInputRequiredResultHandlingAsync(Task<JsonNode?> 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);
|
|
}
|