From b5195b4ff7dcf1dd5453579fc6fd599c6a9dda51 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Thu, 25 Jun 2026 17:31:03 -0700 Subject: [PATCH 01/16] Add generic public seams to Core for Tasks extension extraction - Add ResultOrAlternate replacing task-specific types in server pipeline - Add McpServerRequestHandler for custom request handler registration (seam #1) - Add McpServerOptions.RequestHandlers property with wiring in McpServerImpl - Rename CallToolWithTaskHandler/Filters to CallToolWithAlternateHandler/Filters - Rename SetTaskAugmented to SetWithAlternate (remove tasks/get guard) - Rename InvokeToolAsTask to InvokeToolWithAlternate - Rename BuildInitialTaskToolFilter to BuildInitialAlternateToolFilter - Make McpClient.ResolveInputRequestsAsync public (seam #4) - Update test references to use new names - Adapt TaskHandlerConfigurationValidationTests for removed guard Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Client/McpClient.cs | 3 +- .../Client/McpClientImpl.cs | 2 +- .../Protocol/ResultOrAlternate.cs | 86 ++++++++ .../RequestHandlers.cs | 29 +-- .../Server/McpRequestFilters.cs | 14 +- .../Server/McpServer.Methods.cs | 188 +++++++++++------- .../Server/McpServerHandlers.cs | 22 +- .../Server/McpServerImpl.cs | 105 ++++++---- .../Server/McpServerOptions.cs | 18 +- .../Server/McpServerRequestHandler.cs | 35 ++++ .../Protocol/TaskSerializationTests.cs | 8 +- .../Server/McpServerTaskTests.cs | 18 +- .../Server/McpServerTasksNoStoreTests.cs | 4 +- .../Server/McpTaskStoreTests.cs | 2 +- ...TaskHandlerConfigurationValidationTests.cs | 33 ++- .../Server/TaskPollStuckDetectorTests.cs | 4 +- .../Server/TaskStoreOrphanedTaskTests.cs | 8 +- 17 files changed, 379 insertions(+), 200 deletions(-) create mode 100644 src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs create mode 100644 src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs diff --git a/src/ModelContextProtocol.Core/Client/McpClient.cs b/src/ModelContextProtocol.Core/Client/McpClient.cs index da85e9792..eaa27d70e 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.cs @@ -82,7 +82,8 @@ protected McpClient() /// /// The to monitor for cancellation requests. /// A dictionary of responses keyed by the same identifiers as the input requests. - private protected abstract ValueTask> ResolveInputRequestsAsync( + [Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)] + public abstract ValueTask> ResolveInputRequestsAsync( IDictionary inputRequests, CancellationToken cancellationToken); /// diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index 39f6579e3..d5aa44709 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -182,7 +182,7 @@ private void RegisterHandlers(McpClientOptions options, NotificationHandlers not private protected override int MaxConsecutiveStuckPolls => _options.MaxConsecutiveStuckPolls; /// - private protected override async ValueTask> ResolveInputRequestsAsync( + public override async ValueTask> ResolveInputRequestsAsync( IDictionary inputRequests, CancellationToken cancellationToken) { diff --git a/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs b/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs new file mode 100644 index 000000000..72ba2e67d --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs @@ -0,0 +1,86 @@ +using System.Text.Json.Serialization.Metadata; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the result of a request that may return either the standard result or an alternate +/// subtype for scenarios like asynchronous task execution. +/// +/// The standard result type for the request (e.g., ). +/// +/// +/// Extensions that augment request handling (such as the Tasks extension) use this type to indicate +/// that the server returned an alternate result instead of the normal one. The alternate carries its +/// own so the transport layer can serialize it without compile-time knowledge +/// of the concrete type. +/// +/// +/// Use to determine which variant was returned, then access either +/// for the immediate result or for the alternate. +/// +/// +public class ResultOrAlternate where TResult : Result +{ + private readonly TResult? _result; + private readonly Result? _alternate; + private readonly JsonTypeInfo? _alternateTypeInfo; + + /// + /// Initializes a new instance of with an immediate result. + /// + /// The standard result returned by the server. + public ResultOrAlternate(TResult result) + { + Throw.IfNull(result); + _result = result; + } + + /// + /// Initializes a new instance of with an alternate result. + /// + /// The alternate result. + /// The used to serialize the alternate result. + public ResultOrAlternate(Result alternate, JsonTypeInfo alternateTypeInfo) + { + Throw.IfNull(alternate); + Throw.IfNull(alternateTypeInfo); + _alternate = alternate; + _alternateTypeInfo = alternateTypeInfo; + } + + /// + /// Gets a value indicating whether the server returned an alternate result instead of the standard result. + /// + public bool IsAlternate => _alternate is not null; + + /// + /// Gets the immediate result, or if the server returned an alternate. + /// + public TResult? Result => _result; + + /// + /// Gets the alternate result, or if the server returned the standard result. + /// + public Result? Alternate => _alternate; + + /// + /// Gets the for serializing the alternate result, or + /// if the server returned the standard result. + /// + public JsonTypeInfo? AlternateTypeInfo => _alternateTypeInfo; + + /// + /// Implicitly converts a to a + /// wrapping the immediate result. + /// + /// The result to wrap. + public static implicit operator ResultOrAlternate(TResult result) => new(result); + + /// + /// Implicitly converts a to a + /// wrapping the task handle as an alternate result using the default serialization context. + /// + /// The task creation result to wrap. + public static implicit operator ResultOrAlternate(CreateTaskResult taskCreated) => + new(taskCreated, McpJsonUtilities.JsonContext.Default.CreateTaskResult); +} diff --git a/src/ModelContextProtocol.Core/RequestHandlers.cs b/src/ModelContextProtocol.Core/RequestHandlers.cs index f15ce316c..ff167cc7d 100644 --- a/src/ModelContextProtocol.Core/RequestHandlers.cs +++ b/src/ModelContextProtocol.Core/RequestHandlers.cs @@ -47,44 +47,29 @@ public void Set( } /// - /// Registers a handler that may return either a standard result or a - /// for task-augmented execution. + /// Registers a handler that may return either a standard result or an alternate + /// subtype for scenarios like task-augmented execution. /// - public void SetTaskAugmented( + public void SetWithAlternate( string method, - Func>> handler, + Func>> handler, JsonTypeInfo requestTypeInfo, - JsonTypeInfo responseTypeInfo, - JsonTypeInfo taskResultTypeInfo) + JsonTypeInfo responseTypeInfo) where TResult : Result { Throw.IfNull(method); Throw.IfNull(handler); Throw.IfNull(requestTypeInfo); Throw.IfNull(responseTypeInfo); - Throw.IfNull(taskResultTypeInfo); this[method] = async (request, cancellationToken) => { TParams typedRequest = JsonSerializer.Deserialize(request.Params, requestTypeInfo)!; var augmented = await handler(typedRequest, request, cancellationToken).ConfigureAwait(false); - if (augmented.IsTask) + if (augmented.IsAlternate) { - // Guard against a misconfiguration where a handler opts into task-augmented - // execution but the server has no task lifecycle handlers wired up. Without - // tasks/get, a client that received a CreateTaskResult would have no way to - // poll the task to completion. Configure McpServerOptions.TaskStore or set - // the task handlers explicitly via McpServerOptions.Handlers. - if (!ContainsKey(RequestMethods.TasksGet)) - { - throw new InvalidOperationException( - $"Handler for '{method}' returned a {nameof(CreateTaskResult)}, but the server has no " + - $"'{RequestMethods.TasksGet}' handler registered. Configure McpServerOptions.TaskStore " + - "or set the task handlers explicitly in McpServerOptions.Handlers before starting the server."); - } - - return JsonSerializer.SerializeToNode(augmented.TaskCreated!, taskResultTypeInfo); + return JsonSerializer.SerializeToNode(augmented.Alternate!, augmented.AlternateTypeInfo!); } return JsonSerializer.SerializeToNode(augmented.Result!, responseTypeInfo); diff --git a/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs b/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs index 9d9774e8b..c31125af7 100644 --- a/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs +++ b/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs @@ -42,7 +42,7 @@ public IList> ListTool /// requests. The handler should implement logic to execute the requested tool and return appropriate results. /// /// - /// Cannot be used together with . If both are non-empty at configuration time, + /// Cannot be used together with . If both are non-empty at configuration time, /// an will be thrown. /// /// @@ -57,21 +57,21 @@ public IList> CallToolFi } /// - /// Gets or sets the filters for the call-tool handler pipeline with task support. + /// Gets or sets the filters for the call-tool handler pipeline with alternate result support. /// /// /// - /// These filters wrap the task-augmented call-tool handler whose return type is - /// . Use these filters when the server's tool pipeline - /// supports returning either an immediate or a - /// for asynchronous execution. + /// These filters wrap the alternate-result call-tool handler whose return type is + /// . Use these filters when the server's tool pipeline + /// supports returning either an immediate or an alternate + /// subtype for asynchronous execution. /// /// /// Cannot be used together with . If both are non-empty at configuration time, /// an will be thrown. /// /// - public IList>> CallToolWithTaskFilters + public IList>> CallToolWithAlternateFilters { get => field ??= []; set diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs index 4f739c28a..657f58ba0 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs @@ -23,6 +23,52 @@ public abstract partial class McpServer : McpSession private static Dictionary>? s_elicitAllowedProperties = null; + /// + /// Ambient interceptor that, when installed, redirects server-initiated requests + /// (, + /// , and + /// ) away from the + /// transport. The interceptor receives the request method and pre-serialized parameters and + /// returns the serialized result. Used by extensions (such as the Tasks extension) to surface + /// these requests through an alternate channel during background execution. + /// + private static readonly AsyncLocal>?> s_outgoingRequestInterceptor = new(); + + /// + /// Gets the currently installed outgoing-request interceptor for the ambient execution context, if any. + /// + internal static Func>? CurrentOutgoingRequestInterceptor => s_outgoingRequestInterceptor.Value; + + /// + /// Installs an interceptor that redirects server-initiated requests for the duration of the + /// returned scope on the current asynchronous execution context. + /// + /// + /// The interceptor invoked for each outgoing request. It receives the request method, the + /// pre-serialized request parameters (or ), and a cancellation token, and + /// returns the serialized result (or to indicate no result). + /// + /// An that restores the previous interceptor when disposed. + /// is . + /// + /// While an interceptor is installed, the redirected methods skip their client-capability checks, + /// because the alternate channel is responsible for delivering the request to the client. + /// + [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] + public IDisposable InterceptOutgoingRequests(Func> interceptor) + { + Throw.IfNull(interceptor); + + var previous = s_outgoingRequestInterceptor.Value; + s_outgoingRequestInterceptor.Value = interceptor; + return new OutgoingRequestInterceptorScope(previous); + } + + private sealed class OutgoingRequestInterceptorScope(Func>? previous) : IDisposable + { + public void Dispose() => s_outgoingRequestInterceptor.Value = previous; + } + /// /// Creates a new instance of an . /// @@ -74,14 +120,13 @@ public ValueTask SampleAsync( { Throw.IfNull(requestParams); - // If executing inside a background task, redirect sampling through the task store. - // Capability checks (ThrowIfSamplingUnsupported) are intentionally skipped here because the - // client opted into the tasks extension when submitting the originating request, and input - // requests are delivered through the tasks/get response channel rather than as direct - // server->client requests. See SendRequestViaTaskAsync remarks. - if (McpTaskExecutionContext.Current.Value is { } taskContext) + // If an outgoing-request interceptor is installed (e.g., during background task execution), + // redirect sampling through it. Capability checks (ThrowIfSamplingUnsupported) are + // intentionally skipped because the interceptor's alternate channel is responsible for + // delivering the request to the client. See SendRequestViaInterceptorAsync remarks. + if (CurrentOutgoingRequestInterceptor is { } interceptor) { - return SendRequestViaTaskAsync(taskContext, RequestMethods.SamplingCreateMessage, requestParams, + return SendRequestViaInterceptorAsync(interceptor, RequestMethods.SamplingCreateMessage, requestParams, McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams, McpJsonUtilities.JsonContext.Default.CreateMessageResult, cancellationToken); @@ -282,14 +327,13 @@ public ValueTask RequestRootsAsync( { Throw.IfNull(requestParams); - // If executing inside a background task, redirect through the task store. - // Capability checks (ThrowIfRootsUnsupported) are intentionally skipped here because the - // client opted into the tasks extension when submitting the originating request, and input - // requests are delivered through the tasks/get response channel rather than as direct - // server->client requests. See SendRequestViaTaskAsync remarks. - if (McpTaskExecutionContext.Current.Value is { } taskContext) + // If an outgoing-request interceptor is installed (e.g., during background task execution), + // redirect through it. Capability checks (ThrowIfRootsUnsupported) are intentionally skipped + // because the interceptor's alternate channel is responsible for delivering the request to + // the client. See SendRequestViaInterceptorAsync remarks. + if (CurrentOutgoingRequestInterceptor is { } interceptor) { - return SendRequestViaTaskAsync(taskContext, RequestMethods.RootsList, requestParams, + return SendRequestViaInterceptorAsync(interceptor, RequestMethods.RootsList, requestParams, McpJsonUtilities.JsonContext.Default.ListRootsRequestParams, McpJsonUtilities.JsonContext.Default.ListRootsResult, cancellationToken); @@ -334,18 +378,15 @@ public async ValueTask ElicitAsync( { Throw.IfNull(requestParams); - // If executing inside a background task, redirect elicitation through the task store. - // Capability checks (ThrowIfElicitationUnsupported) are intentionally skipped here because - // the client opted into the tasks extension when submitting the originating request, and - // input requests are delivered through the tasks/get response channel rather than as - // direct server->client requests. See SendRequestViaTaskAsync remarks. - if (McpTaskExecutionContext.Current.Value is { } taskContext) - { - var taskResult = await SendRequestViaTaskAsync(taskContext, RequestMethods.ElicitationCreate, requestParams, - McpJsonUtilities.JsonContext.Default.ElicitRequestParams, - McpJsonUtilities.JsonContext.Default.ElicitResult, - cancellationToken).ConfigureAwait(false); - return taskResult ?? new ElicitResult { Action = "cancel" }; + // If an outgoing-request interceptor is installed (e.g., during background task execution), + // redirect elicitation through it. Capability checks (ThrowIfElicitationUnsupported) are + // intentionally skipped because the interceptor's alternate channel is responsible for + // delivering the request to the client. See SendRequestViaInterceptorAsync remarks. + if (CurrentOutgoingRequestInterceptor is { } interceptor) + { + var paramsNode = JsonSerializer.SerializeToNode(requestParams, McpJsonUtilities.JsonContext.Default.ElicitRequestParams); + var resultNode = await interceptor(RequestMethods.ElicitationCreate, paramsNode, cancellationToken).ConfigureAwait(false); + return resultNode?.Deserialize(McpJsonUtilities.JsonContext.Default.ElicitResult) ?? new ElicitResult { Action = "cancel" }; } ThrowIfElicitationUnsupported(requestParams); @@ -607,69 +648,68 @@ public IDisposable CreateMcpTaskScope( Throw.IfNull(taskId); Throw.IfNull(store); - var previous = McpTaskExecutionContext.Current.Value; - McpTaskExecutionContext.Current.Value = new McpTaskExecutionContext + return InterceptOutgoingRequests(async (method, paramsNode, cancellationToken) => { - TaskId = taskId, - Store = store, - }; - return new McpTaskExecutionContext.Scope(previous); + var requestId = Guid.NewGuid().ToString("N"); + + var inputRequest = new InputRequest + { + Method = method, + Params = paramsNode is null + ? default + : JsonSerializer.SerializeToElement(paramsNode, McpJsonUtilities.DefaultOptions.GetTypeInfo()), + }; + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void handler(InputResponseReceivedEventArgs args) + { + if (args.TaskId == taskId && args.RequestId == requestId) + { + tcs.TrySetResult(args.Response); + } + } + + store.InputResponseReceived += handler; + try + { + await store.SetInputRequestsAsync( + taskId, + new Dictionary { [requestId] = inputRequest }, + cancellationToken).ConfigureAwait(false); + + var response = await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + + return JsonNode.Parse(response.RawValue.GetRawText()); + } + finally + { + store.InputResponseReceived -= handler; + } + }); } /// - /// Sends a server-initiated request through the task store as an input request, then awaits the response. + /// Sends a server-initiated request through the installed outgoing-request interceptor, then awaits the response. /// /// - /// When executing inside a task scope, capability negotiation checks (such as + /// When an interceptor is installed, capability negotiation checks (such as /// , , and /// ) are intentionally skipped by the callers - /// of this helper. The task channel itself is the negotiated capability: the client opted - /// in to the tasks extension when it submitted the originating request, and is responsible - /// for handling or rejecting the input requests surfaced through tasks/get. + /// of this helper. The interceptor's alternate channel is the negotiated capability and is + /// responsible for delivering the request to the client or rejecting it. /// - private async ValueTask SendRequestViaTaskAsync( - McpTaskExecutionContext taskContext, + private async ValueTask SendRequestViaInterceptorAsync( + Func> interceptor, string method, TRequest request, JsonTypeInfo requestTypeInfo, JsonTypeInfo responseTypeInfo, CancellationToken cancellationToken) { - var requestId = Guid.NewGuid().ToString("N"); - var paramsJson = JsonSerializer.SerializeToElement(request, requestTypeInfo); - - var inputRequest = new InputRequest - { - Method = method, - Params = paramsJson, - }; - - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - void handler(InputResponseReceivedEventArgs args) - { - if (args.TaskId == taskContext.TaskId && args.RequestId == requestId) - { - tcs.TrySetResult(args.Response); - } - } - - taskContext.Store.InputResponseReceived += handler; - try - { - await taskContext.Store.SetInputRequestsAsync( - taskContext.TaskId, - new Dictionary { [requestId] = inputRequest }, - cancellationToken).ConfigureAwait(false); - - var response = await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); - - return response.Deserialize(responseTypeInfo)!; - } - finally - { - taskContext.Store.InputResponseReceived -= handler; - } + var paramsNode = JsonSerializer.SerializeToNode(request, requestTypeInfo); + var resultNode = await interceptor(method, paramsNode, cancellationToken).ConfigureAwait(false); + return resultNode is null ? default! : resultNode.Deserialize(responseTypeInfo)!; } private void ThrowIfElicitationUnsupported(ElicitRequestParams request) diff --git a/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs b/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs index 4f9509b9d..3aa1fdea6 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs @@ -42,19 +42,19 @@ public sealed class McpServerHandlers /// /// This handler is invoked when a client makes a call to a tool that isn't found in the collection. /// The handler should implement logic to execute the requested tool and return appropriate results. - /// Use instead if the tool may return a - /// for asynchronous execution. + /// Use instead if the tool may return an alternate result + /// (such as ) for asynchronous execution. /// - /// is already set. + /// is already set. public McpRequestHandler? CallToolHandler { get; set { - if (value is not null && CallToolWithTaskHandler is not null) + if (value is not null && CallToolWithAlternateHandler is not null) { throw new InvalidOperationException( - $"Cannot set {nameof(CallToolHandler)} when {nameof(CallToolWithTaskHandler)} is already set. Only one call tool handler may be configured."); + $"Cannot set {nameof(CallToolHandler)} when {nameof(CallToolWithAlternateHandler)} is already set. Only one call tool handler may be configured."); } field = value; @@ -62,20 +62,20 @@ public McpRequestHandler? CallToolHandler } /// - /// Gets or sets the handler for requests with task support. + /// Gets or sets the handler for requests with alternate result support. /// /// /// /// This handler is invoked when a client makes a call to a tool, allowing the tool to return either - /// a for immediate results or a for - /// long-running asynchronous operations. + /// a for immediate results or an alternate subtype + /// (such as ) for long-running asynchronous operations. /// /// /// Cannot be set if is already set. /// /// /// is already set. - public McpRequestHandler>? CallToolWithTaskHandler + public McpRequestHandler>? CallToolWithAlternateHandler { get; set @@ -83,7 +83,7 @@ public McpRequestHandler is the recommended way to wire all three /// task lifecycle handlers (, , /// and ) from a single source while still allowing explicit - /// handlers to override any slot. If can return a + /// handlers to override any slot. If can return a /// but no is configured (either /// directly or via a task store), the server throws /// when processing the request so misconfigured deployments fail loudly instead of producing diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index f6eb0d60e..11ba56734 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -97,6 +97,7 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact ConfigureExperimentalAndExtensions(options); ConfigureTasks(options); ConfigureMrtr(); + ConfigureCustomRequestHandlers(options); // Register any notification handlers that were provided. if (options.Handlers.NotificationHandlers is { } notificationHandlers) @@ -865,6 +866,26 @@ private void ConfigureExperimentalAndExtensions(McpServerOptions options) ServerCapabilities.Extensions = options.Capabilities?.Extensions; } + private void ConfigureCustomRequestHandlers(McpServerOptions options) + { +#pragma warning disable MCPEXP002 + if (options.RequestHandlers is not { Count: > 0 } customHandlers) +#pragma warning restore MCPEXP002 + { + return; + } + + foreach (var entry in customHandlers) + { + SetRawHandler(entry.Method, entry.Handler); + } + } + + private void SetRawHandler(string method, Func> handler) + { + _requestHandlers[method] = (request, ct) => handler(request, ct).AsTask(); + } + private void ConfigureResources(McpServerOptions options) { var listResourcesHandler = options.Handlers.ListResourcesHandler; @@ -1134,11 +1155,11 @@ private void ConfigureTools(McpServerOptions options) { var listToolsHandler = options.Handlers.ListToolsHandler; var callToolHandler = options.Handlers.CallToolHandler; - var callToolWithTaskHandler = options.Handlers.CallToolWithTaskHandler; + var callToolWithAlternateHandler = options.Handlers.CallToolWithAlternateHandler; var tools = options.ToolCollection; var toolsCapability = options.Capabilities?.Tools; - if (listToolsHandler is null && callToolHandler is null && callToolWithTaskHandler is null && tools is null && + if (listToolsHandler is null && callToolHandler is null && callToolWithAlternateHandler is null && tools is null && toolsCapability is null) { return; @@ -1150,17 +1171,17 @@ private void ConfigureTools(McpServerOptions options) var listChanged = toolsCapability?.ListChanged; var callToolFilters = options.Filters.Request.CallToolFilters; - var callToolWithTaskFilters = options.Filters.Request.CallToolWithTaskFilters; + var callToolWithAlternateFilters = options.Filters.Request.CallToolWithAlternateFilters; - // Validate: cannot mix non-task filters/handler with task filters/handler. - bool hasNonTaskPath = callToolHandler is not null || callToolFilters.Count > 0; - bool hasTaskPath = callToolWithTaskHandler is not null || callToolWithTaskFilters.Count > 0; + // Validate: cannot mix non-alternate filters/handler with alternate filters/handler. + bool hasNonAlternatePath = callToolHandler is not null || callToolFilters.Count > 0; + bool hasAlternatePath = callToolWithAlternateHandler is not null || callToolWithAlternateFilters.Count > 0; - if (hasNonTaskPath && hasTaskPath) + if (hasNonAlternatePath && hasAlternatePath) { throw new InvalidOperationException( - $"Cannot mix non-task ({nameof(McpServerHandlers.CallToolHandler)}/{nameof(McpRequestFilters.CallToolFilters)}) " + - $"with task-based ({nameof(McpServerHandlers.CallToolWithTaskHandler)}/{nameof(McpRequestFilters.CallToolWithTaskFilters)}). Use one style or the other."); + $"Cannot mix non-alternate ({nameof(McpServerHandlers.CallToolHandler)}/{nameof(McpRequestFilters.CallToolFilters)}) " + + $"with alternate-based ({nameof(McpServerHandlers.CallToolWithAlternateHandler)}/{nameof(McpRequestFilters.CallToolWithAlternateFilters)}). Use one style or the other."); } // Handle tools provided via DI by augmenting the list handler. @@ -1202,32 +1223,32 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) listToolsHandler = BuildFilterPipeline(listToolsHandler, options.Filters.Request.ListToolsFilters); - // Build the unified task-augmented handler from one of the two paths. - if (hasTaskPath) + // Build the unified alternate-result handler from one of the two paths. + if (hasAlternatePath) { - // Case 2: task filter + task handler - callToolWithTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams)); + // Case 2: alternate filter + alternate handler + callToolWithAlternateHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams)); // Augment with DI tools. if (tools is not null) { - var originalHandler = callToolWithTaskHandler; - callToolWithTaskHandler = (request, cancellationToken) => + var originalHandler = callToolWithAlternateHandler; + callToolWithAlternateHandler = (request, cancellationToken) => { if (request.MatchedPrimitive is McpServerTool tool) { - return InvokeToolAsTask(tool, request, cancellationToken); + return InvokeToolWithAlternate(tool, request, cancellationToken); } return originalHandler(request, cancellationToken); }; } - callToolWithTaskHandler = BuildFilterPipeline(callToolWithTaskHandler, callToolWithTaskFilters, BuildInitialTaskToolFilter(tools)); + callToolWithAlternateHandler = BuildFilterPipeline(callToolWithAlternateHandler, callToolWithAlternateFilters, BuildInitialAlternateToolFilter(tools)); } else { - // Case 1: non-task filter + non-task handler → apply filters, then convert to task-based + // Case 1: non-alternate filter + non-alternate handler -> apply filters, then convert to alternate-based callToolHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams)); // Augment with DI tools. @@ -1247,9 +1268,9 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) callToolHandler = BuildFilterPipeline(callToolHandler, callToolFilters, BuildInitialCallToolFilter(tools)); - // Convert to task-based. + // Convert to alternate-based. var finalCallToolHandler = callToolHandler; - callToolWithTaskHandler = async (request, cancellationToken) => + callToolWithAlternateHandler = async (request, cancellationToken) => await finalCallToolHandler(request, cancellationToken).ConfigureAwait(false); } @@ -1257,8 +1278,8 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) // the tool execution is offloaded to the background via the store. if (options.TaskStore is { } taskStore) { - var innerTaskHandler = callToolWithTaskHandler; - callToolWithTaskHandler = async (request, cancellationToken) => + var innerAlternateHandler = callToolWithAlternateHandler; + callToolWithAlternateHandler = async (request, cancellationToken) => { // The SEP-2663 Tasks extension requires the 2026-07-28 or later revision: the task wire shapes we ship do not // interoperate with legacy (<= 2025-11-25) peers. Only materialize a task when the @@ -1285,16 +1306,16 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) { try { - var augmented = await innerTaskHandler(request, taskCancellationToken).ConfigureAwait(false); - if (augmented.IsTask) + var augmented = await innerAlternateHandler(request, taskCancellationToken).ConfigureAwait(false); + if (augmented.IsAlternate) { // 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. + // 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.", + Message = $"{nameof(McpServerOptions.TaskStore)} is configured and the {nameof(McpServerHandlers.CallToolWithAlternateHandler)} returned IsAlternate = 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); @@ -1319,7 +1340,7 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) { 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.", + $"use {nameof(McpServerHandlers.CallToolWithAlternateHandler)} 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); @@ -1348,10 +1369,12 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) } }, CancellationToken.None); - return ToCreateTaskResult(taskInfo); + return new ResultOrAlternate( + ToCreateTaskResult(taskInfo), + McpJsonUtilities.JsonContext.Default.CreateTaskResult); } - return await innerTaskHandler(request, cancellationToken).ConfigureAwait(false); + return await innerAlternateHandler(request, cancellationToken).ConfigureAwait(false); }; } @@ -1363,12 +1386,11 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) McpJsonUtilities.JsonContext.Default.ListToolsRequestParams, McpJsonUtilities.JsonContext.Default.ListToolsResult); - SetTaskAugmentedHandler( + SetWithAlternateHandler( RequestMethods.ToolsCall, - callToolWithTaskHandler, + callToolWithAlternateHandler, McpJsonUtilities.JsonContext.Default.CallToolRequestParams, - McpJsonUtilities.JsonContext.Default.CallToolResult, - McpJsonUtilities.JsonContext.Default.CreateTaskResult); + McpJsonUtilities.JsonContext.Default.CallToolResult); } private static CreateTaskResult ToCreateTaskResult(McpTaskInfo info) => new() @@ -1450,7 +1472,7 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) _ => throw new InvalidOperationException($"Unknown task status: {info.Status}"), }; - private static async ValueTask> InvokeToolAsTask( + private static async ValueTask> InvokeToolWithAlternate( McpServerTool tool, RequestContext request, CancellationToken cancellationToken) @@ -1501,7 +1523,7 @@ private McpRequestFilter BuildInitialCall } }; - private McpRequestFilter> BuildInitialTaskToolFilter( + private McpRequestFilter> BuildInitialAlternateToolFilter( McpServerPrimitiveCollection? tools) => handler => async (request, cancellationToken) => { @@ -1514,7 +1536,7 @@ private McpRequestFilter( requestTypeInfo, responseTypeInfo); } - private void SetTaskAugmentedHandler( + private void SetWithAlternateHandler( string method, - McpRequestHandler> handler, + McpRequestHandler> handler, JsonTypeInfo requestTypeInfo, - JsonTypeInfo responseTypeInfo, - JsonTypeInfo taskResultTypeInfo) + JsonTypeInfo responseTypeInfo) where TResult : Result { - _requestHandlers.SetTaskAugmented(method, + _requestHandlers.SetWithAlternate(method, (request, jsonRpcRequest, cancellationToken) => InvokeHandlerAsync(handler, request, jsonRpcRequest, cancellationToken), - requestTypeInfo, responseTypeInfo, taskResultTypeInfo); + requestTypeInfo, responseTypeInfo); } private static McpRequestHandler BuildFilterPipeline( diff --git a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs index eb99913d5..d31d7882b 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs @@ -1,7 +1,7 @@ using ModelContextProtocol.Protocol; using System.Diagnostics.CodeAnalysis; -#pragma warning disable MCPEXP001 +#pragma warning disable MCPEXP001, MCPEXP002 namespace ModelContextProtocol.Server; @@ -204,4 +204,20 @@ public McpServerFilters Filters /// /// public IMcpTaskStore? TaskStore { get; set; } + + /// + /// Gets or sets custom request handlers to register with the server. + /// + /// + /// + /// Each registers a raw JSON-RPC method handler that + /// bypasses the typed handler infrastructure. This enables extensions to register handlers + /// for methods not known to Core at compile time. + /// + /// + /// Handlers registered here take precedence over built-in handlers for the same method. + /// + /// + [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] + public IList? RequestHandlers { get; set; } } diff --git a/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs b/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs new file mode 100644 index 000000000..be845f599 --- /dev/null +++ b/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs @@ -0,0 +1,35 @@ +using ModelContextProtocol.Protocol; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Server; + +/// +/// Represents a custom request handler that can be registered with the MCP server to handle +/// arbitrary JSON-RPC methods. +/// +/// +/// +/// Custom request handlers are registered via and +/// are invoked when a JSON-RPC request with the matching is received. +/// The handler receives the raw and returns a serialized +/// response, giving extensions full control over request/response serialization. +/// +/// +[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] +public sealed class McpServerRequestHandler +{ + /// + /// Gets the JSON-RPC method name this handler responds to. + /// + public required string Method { get; init; } + + /// + /// Gets the handler function that processes incoming requests for the specified method. + /// + /// + /// The handler receives the full and a , + /// and returns a serialized response (or for void methods). + /// + public required Func> Handler { get; init; } +} diff --git a/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs index 86acc57f6..0a0f510a4 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs @@ -439,10 +439,10 @@ public static void TaskStatusNotificationParams_InputRequired_RoundTrip() #endregion - #region ResultOrCreatedTask + #region ResultOrAlternate [Fact] - public static void ResultOrCreatedTask_ImplicitConversion_FromResult() + public static void ResultOrAlternate_ImplicitConversion_FromResult() { CallToolResult callResult = new() { Content = [new TextContentBlock { Text = "hi" }] }; @@ -454,7 +454,7 @@ public static void ResultOrCreatedTask_ImplicitConversion_FromResult() } [Fact] - public static void ResultOrCreatedTask_ImplicitConversion_FromCreateTaskResult() + public static void ResultOrAlternate_ImplicitConversion_FromCreateTaskResult() { CreateTaskResult taskCreated = new() { @@ -472,7 +472,7 @@ public static void ResultOrCreatedTask_ImplicitConversion_FromCreateTaskResult() } [Fact] - public static void ResultOrCreatedTask_IsTask_FalseForResult_TrueForTask() + public static void ResultOrAlternate_IsTask_FalseForResult_TrueForTask() { var result = new ResultOrCreatedTask(new CallToolResult()); var task = new ResultOrCreatedTask(new CreateTaskResult diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs index 9708722f3..9b73d0b0e 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs @@ -30,7 +30,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer { options.Capabilities ??= new ServerCapabilities(); - options.Handlers.CallToolWithTaskHandler = async (context, cancellationToken) => + options.Handlers.CallToolWithAlternateHandler = async (context, cancellationToken) => { _capturedMeta = context.Params?.Meta; var store = context.Server.Services!.GetRequiredService(); @@ -376,7 +376,7 @@ await client.UpdateTaskAsync(new UpdateTaskRequestParams public async Task CallToolRawAsync_InjectsTaskCapabilityInMeta() { // Verify the server receives the task extension in _meta by intercepting - // the handler. The CallToolWithTaskHandler already receives the request, + // the handler. The CallToolWithAlternateHandler already receives the request, // so we can observe the meta there. We test the client-side injection indirectly // by confirming the server returns a task result (which requires the capability signal). await using var client = await CreateMcpClientForServer(); @@ -506,9 +506,9 @@ public async Task CallToolAsync_RespectsServerPollInterval() } [Fact] - public async Task CallToolWithTaskHandler_ImplicitConversion_ReturnCallToolResult() + public async Task CallToolWithAlternateHandler_ImplicitConversion_ReturnCallToolResult() { - // Verify that the implicit conversion from CallToolResult to ResultOrCreatedTask works + // Verify that the implicit conversion from CallToolResult to ResultOrAlternate works // in the handler context — this is already tested by "immediate-tool" working correctly. await using var client = await CreateMcpClientForServer(); @@ -520,11 +520,11 @@ public async Task CallToolWithTaskHandler_ImplicitConversion_ReturnCallToolResul } [Fact] - public async Task CallToolHandler_And_CallToolWithTaskHandler_AreMutuallyExclusive() + public async Task CallToolHandler_And_CallToolWithAlternateHandler_AreMutuallyExclusive() { var handlers = new McpServerHandlers(); - handlers.CallToolWithTaskHandler = async (ctx, ct) => new CallToolResult(); + handlers.CallToolWithAlternateHandler = async (ctx, ct) => new CallToolResult(); Assert.Throws(() => handlers.CallToolHandler = async (ctx, ct) => new CallToolResult()); @@ -532,7 +532,7 @@ public async Task CallToolHandler_And_CallToolWithTaskHandler_AreMutuallyExclusi handlers.CallToolHandler = async (ctx, ct) => new CallToolResult(); Assert.Throws(() => - handlers.CallToolWithTaskHandler = async (ctx, ct) => new CallToolResult()); + handlers.CallToolWithAlternateHandler = async (ctx, ct) => new CallToolResult()); } [Fact] @@ -544,8 +544,8 @@ public async Task CallToolHandler_CanBeSetToNull_ThenOtherCanBeSet() handlers.CallToolHandler = null; // Now setting the other should work - handlers.CallToolWithTaskHandler = async (ctx, ct) => new CallToolResult(); - Assert.NotNull(handlers.CallToolWithTaskHandler); + handlers.CallToolWithAlternateHandler = async (ctx, ct) => new CallToolResult(); + Assert.NotNull(handlers.CallToolWithAlternateHandler); } /// diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs index 9e264af78..3f3c411f6 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs @@ -10,7 +10,7 @@ namespace ModelContextProtocol.Tests.Server; /// /// Pins the behavior when a client signals the SEP-2575 tasks opt-in via _meta but the server -/// has neither an nor a +/// has neither an nor a /// configured. The expected behavior is a silent synchronous fallback: the server returns the normal /// with no Task envelope and no exception. /// @@ -25,7 +25,7 @@ public McpServerTasksNoStoreTests(ITestOutputHelper testOutputHelper) : base(tes protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - // Intentionally do NOT configure TaskStore or CallToolWithTaskHandler. + // Intentionally do NOT configure TaskStore or CallToolWithAlternateHandler. mcpServerBuilder.WithTools(); } diff --git a/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs index 09a501ab8..f28761812 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs @@ -233,7 +233,7 @@ public async Task InputRequiredException_FromTool_FailsTaskWithActionableMessage var message = failed.Error.GetProperty("message").GetString(); Assert.NotNull(message); Assert.Contains("MRTR", message); - Assert.Contains(nameof(McpServerHandlers.CallToolWithTaskHandler), message); + Assert.Contains(nameof(McpServerHandlers.CallToolWithAlternateHandler), message); } [Fact] diff --git a/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs index 8cd2f7839..3c2d1d5ba 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs @@ -6,9 +6,10 @@ namespace ModelContextProtocol.Tests.Server; /// -/// Verifies the runtime validation that fires when a handler opts into task-augmented execution -/// (returns a ) without the server having any tasks/get -/// handler registered. +/// Verifies behavior when a handler returns a alternate without +/// the server having any tasks/get handler registered. After the ResultOrAlternate +/// generalization, the Core server no longer guards against this -- the extension is responsible +/// for ensuring lifecycle handlers are registered. /// public class TaskHandlerConfigurationValidationTests : ClientServerTestBase { @@ -25,10 +26,10 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer { options.Capabilities ??= new ServerCapabilities(); - // Intentionally configure a task-augmented handler without TaskStore or any of the + // Configure a task-augmented handler without TaskStore or any of the // task lifecycle handlers (GetTaskHandler/UpdateTaskHandler/CancelTaskHandler). - options.Handlers.CallToolWithTaskHandler = (context, cancellationToken) => - new ValueTask>(new CreateTaskResult + options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => + new ValueTask>(new CreateTaskResult { TaskId = "orphan-task", Status = McpTaskStatus.Working, @@ -39,21 +40,15 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer } [Fact] - public async Task CallTool_ReturningCreateTaskResult_WithoutTasksGetHandler_ThrowsAtRequestTime() + public async Task ServerAcceptsAlternateHandler_WithoutTasksGetHandler_NoStartupError() { + // The Core guard that previously threw InvalidOperationException at request time when + // a CallToolWithAlternateHandler returned a CreateTaskResult without tasks/get being + // registered has been removed. The extension is now responsible for that guarantee. + // This test verifies the server starts and connects successfully with such configuration. await using var client = await CreateMcpClientForServer(); - // Client surfaces a generic protocol error (the server intentionally redacts the message - // on the wire), so use the base McpException type and confirm via server-side logs that - // the originating exception was the misconfiguration guard. - await Assert.ThrowsAnyAsync(async () => - await client.CallToolAsync( - new CallToolRequestParams { Name = "anything" }, - cancellationToken: TestContext.Current.CancellationToken)); - - Assert.Contains(MockLoggerProvider.LogMessages, log => - log.Exception is InvalidOperationException ioe && - ioe.Message.Contains("tasks/get", StringComparison.Ordinal) && - ioe.Message.Contains("CreateTaskResult", StringComparison.Ordinal)); + // If we get here, the server accepted the handler config without error. + Assert.NotNull(client); } } diff --git a/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs index 1d17be223..d73fe42a7 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs @@ -32,10 +32,10 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer // CallTool always returns a CreateTaskResult with a tiny poll interval so the // test exercises the threshold in well under a second. - options.Handlers.CallToolWithTaskHandler = (context, cancellationToken) => + options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => { var taskId = Guid.NewGuid().ToString("N"); - return new ValueTask>(new CreateTaskResult + return new ValueTask>(new CreateTaskResult { TaskId = taskId, Status = McpTaskStatus.InputRequired, diff --git a/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs index 303d16e17..719bd5d19 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs @@ -11,7 +11,7 @@ namespace ModelContextProtocol.Tests.Server; /// /// Verifies that when both and -/// are configured and the handler returns +/// are configured and the handler returns /// (IsTask = true), the store's pre-created task is failed with a /// clear error rather than being orphaned in forever. /// @@ -33,8 +33,8 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer // Returning IsTask = true here while TaskStore is also configured is the // misconfiguration the server must guard against. - options.Handlers.CallToolWithTaskHandler = (context, cancellationToken) => - new ValueTask>(new CreateTaskResult + options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => + new ValueTask>(new CreateTaskResult { TaskId = "user-task", Status = McpTaskStatus.Working, @@ -77,6 +77,6 @@ public async Task TaskStoreAndHandler_BothCreatingTasks_FailsStoreTaskWithClearE var message = failed.Error.GetProperty("message").GetString(); Assert.NotNull(message); Assert.Contains(nameof(McpServerOptions.TaskStore), message); - Assert.Contains(nameof(McpServerHandlers.CallToolWithTaskHandler), message); + Assert.Contains(nameof(McpServerHandlers.CallToolWithAlternateHandler), message); } } From 9eee757330b0d8d5dd7ae3a31a9f35616d571532 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Thu, 25 Jun 2026 19:45:31 -0700 Subject: [PATCH 02/16] Extract Tasks into ModelContextProtocol.Extensions.Tasks package - Create src/ModelContextProtocol.Extensions.Tasks/ with csproj, JSON context, server builder extensions, client extension methods - Move task protocol DTOs (CreateTaskResult, GetTaskResult, UpdateTask*, CancelTask*, McpTaskStatus, TaskStatusNotificationParams) to extension - Move server types (IMcpTaskStore, InMemoryMcpTaskStore, McpTaskInfo, InputResponseReceivedEventArgs) to extension - Move task constants (RequestMethods.Tasks*, NotificationMethods.TaskStatus*, MetaKeys.RelatedTask, McpExtensions.Tasks) into TasksProtocol static class - Delete McpTaskExecutionContext, ResultOrCreatedTask from Core - Remove ~18 task [JsonSerializable] entries from McpJsonUtilities - Remove McpServerOptions.TaskStore and McpClientOptions.MaxConsecutiveStuckPolls - Remove task client methods from McpClient (CallToolRawAsync, PollTaskToCompletion, GetTaskAsync, UpdateTaskAsync, CancelTaskAsync, GetMetaWithTaskCapability, ThrowIfTasksNotSupported) - Remove task server methods/handlers (GetTaskHandler, UpdateTaskHandler, CancelTaskHandler, ConfigureTasks, InvokeToolAsTask, task cancellation sources) - Add Tasks_DiagnosticId (MCPEXP001) to Experimentals.cs - Extension WithTasks(store) registers request handlers via seam #1, alternate filter via seam #2, interceptor via seam #3 - Extension client methods: CallToolAsTaskAsync, CallToolWithPollingAsync, GetTaskAsync, UpdateTaskAsync, CancelTaskAsync - Manually serialize/deserialize InputResponses in UpdateTaskAsync to handle internal Core property visibility across assembly boundary - Update test project and TasksExtension sample to reference new package - Update all task test files with new using and extension API Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ModelContextProtocol.slnx | 1 + samples/TasksExtension/Program.cs | 54 +-- samples/TasksExtension/TasksExtension.csproj | 2 + .../Client/McpClient.Methods.cs | 374 +----------------- .../Client/McpClient.cs | 10 +- .../Client/McpClientImpl.cs | 1 - .../Client/McpClientOptions.cs | 37 -- .../McpJsonUtilities.cs | 27 +- .../Protocol/McpExtensions.cs | 18 - .../Protocol/MetaKeys.cs | 20 - .../Protocol/NotificationMethods.cs | 10 - .../Protocol/NotificationParams.cs | 4 +- .../Protocol/RequestMethods.cs | 27 -- .../Protocol/RequestParams.cs | 4 +- .../Protocol/Result.cs | 10 +- .../Protocol/ResultOrAlternate.cs | 8 - .../Server/McpServer.Methods.cs | 87 ---- .../Server/McpServerHandlers.cs | 73 +--- .../Server/McpServerImpl.cs | 311 --------------- .../Server/McpServerOptions.cs | 15 - .../Server/McpTaskExecutionContext.cs | 21 - .../Client/McpTasksClientExtensions.cs | 370 +++++++++++++++++ .../McpTasksJsonContext.cs | 32 ++ ...delContextProtocol.Extensions.Tasks.csproj | 51 +++ .../Protocol/CancelTaskRequestParams.cs | 3 +- .../Protocol/CancelTaskResult.cs | 3 +- .../Protocol/CreateTaskResult.cs | 3 +- .../Protocol/GetTaskRequestParams.cs | 3 +- .../Protocol/GetTaskResult.cs | 8 +- .../Protocol/McpTaskStatus.cs | 3 +- .../Protocol/ResultOrCreatedTask.cs | 13 +- .../Protocol/TaskStatusNotificationParams.cs | 9 +- .../Protocol/UpdateTaskRequestParams.cs | 3 +- .../Protocol/UpdateTaskResult.cs | 3 +- .../Server/IMcpTaskStore.cs | 2 +- .../Server/InMemoryMcpTaskStore.cs | 2 +- .../Server/InputResponseReceivedEventArgs.cs | 2 +- .../Server/McpTaskInfo.cs | 2 +- .../Server/McpTasksBuilderExtensions.cs | 301 ++++++++++++++ .../Server/McpTasksServerExtensions.cs | 109 +++++ .../TasksProtocol.cs | 37 ++ .../Client/McpClientTaskMethodsTests.cs | 32 +- .../ModelContextProtocol.Tests.csproj | 1 + .../Protocol/TaskSerializationTests.cs | 75 ++-- .../Server/InMemoryMcpTaskStoreTests.cs | 1 + .../Server/McpServerTaskTests.cs | 195 +++++---- .../Server/McpServerTasksNoStoreTests.cs | 11 +- .../Server/McpTaskStoreTests.cs | 90 ++--- .../TaskCancellationIntegrationTests.cs | 46 +-- ...TaskHandlerConfigurationValidationTests.cs | 24 +- .../Server/TaskPollStuckDetectorTests.cs | 108 ++--- .../Server/TaskProtocolGatingTests.cs | 27 +- .../Server/TaskStoreOrphanedTaskTests.cs | 32 +- 53 files changed, 1321 insertions(+), 1394 deletions(-) delete mode 100644 src/ModelContextProtocol.Core/Protocol/McpExtensions.cs delete mode 100644 src/ModelContextProtocol.Core/Server/McpTaskExecutionContext.cs create mode 100644 src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs create mode 100644 src/ModelContextProtocol.Extensions.Tasks/McpTasksJsonContext.cs create mode 100644 src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj rename src/{ModelContextProtocol.Core => ModelContextProtocol.Extensions.Tasks}/Protocol/CancelTaskRequestParams.cs (92%) rename src/{ModelContextProtocol.Core => ModelContextProtocol.Extensions.Tasks}/Protocol/CancelTaskResult.cs (90%) rename src/{ModelContextProtocol.Core => ModelContextProtocol.Extensions.Tasks}/Protocol/CreateTaskResult.cs (96%) rename src/{ModelContextProtocol.Core => ModelContextProtocol.Extensions.Tasks}/Protocol/GetTaskRequestParams.cs (90%) rename src/{ModelContextProtocol.Core => ModelContextProtocol.Extensions.Tasks}/Protocol/GetTaskResult.cs (98%) rename src/{ModelContextProtocol.Core => ModelContextProtocol.Extensions.Tasks}/Protocol/McpTaskStatus.cs (94%) rename src/{ModelContextProtocol.Core => ModelContextProtocol.Extensions.Tasks}/Protocol/ResultOrCreatedTask.cs (91%) rename src/{ModelContextProtocol.Core => ModelContextProtocol.Extensions.Tasks}/Protocol/TaskStatusNotificationParams.cs (98%) rename src/{ModelContextProtocol.Core => ModelContextProtocol.Extensions.Tasks}/Protocol/UpdateTaskRequestParams.cs (93%) rename src/{ModelContextProtocol.Core => ModelContextProtocol.Extensions.Tasks}/Protocol/UpdateTaskResult.cs (88%) rename src/{ModelContextProtocol.Core => ModelContextProtocol.Extensions.Tasks}/Server/IMcpTaskStore.cs (99%) rename src/{ModelContextProtocol.Core => ModelContextProtocol.Extensions.Tasks}/Server/InMemoryMcpTaskStore.cs (99%) rename src/{ModelContextProtocol.Core => ModelContextProtocol.Extensions.Tasks}/Server/InputResponseReceivedEventArgs.cs (92%) rename src/{ModelContextProtocol.Core => ModelContextProtocol.Extensions.Tasks}/Server/McpTaskInfo.cs (94%) create mode 100644 src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs create mode 100644 src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksServerExtensions.cs create mode 100644 src/ModelContextProtocol.Extensions.Tasks/TasksProtocol.cs diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index abfb430cb..9020d2fbe 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -69,6 +69,7 @@ + diff --git a/samples/TasksExtension/Program.cs b/samples/TasksExtension/Program.cs index fff353307..905c30a64 100644 --- a/samples/TasksExtension/Program.cs +++ b/samples/TasksExtension/Program.cs @@ -1,18 +1,17 @@ // Demonstrates the MCP tasks extension (SEP-2663): // -// - A server is configured with InMemoryMcpTaskStore so that any [McpServerTool] invocation +// - A server is configured with .WithTasks(store) so that any [McpServerTool] invocation // becomes a background task when the client opts in via the per-request _meta marker. -// - The client invokes the same tool two ways: -// 1. CallToolAsync — the SDK auto-polls until the task completes and returns the final -// CallToolResult, just like a synchronous call. -// 2. CallToolRawAsync — the caller drives the lifecycle manually (GetTaskAsync polls, -// CancelTaskAsync, etc.). Use this when you need to surface progress to a UI or stream -// status updates rather than block on a single await. +// - The client invokes the tool and manually drives the lifecycle via GetTaskAsync. // // Both server and client are wired together in-process over an in-memory pipe so the sample // is self-contained — no separate server process or HTTP transport required. +#pragma warning disable MCPEXP001, MCPEXP002, MCPEXP004 + +using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Tasks; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.ComponentModel; @@ -21,32 +20,27 @@ Pipe clientToServerPipe = new(), serverToClientPipe = new(); -await using McpServer server = McpServer.Create( - new StreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()), - new McpServerOptions - { - // Setting TaskStore is all that's needed for [McpServerTool]-attributed tools to be - // automatically wrapped as background tasks when the client opts in. - TaskStore = new InMemoryMcpTaskStore { DefaultPollIntervalMs = 250 }, - ToolCollection = [McpServerTool.Create(SlowTools.RunReport, new() { Name = "run-report" })], - }); +var store = new InMemoryMcpTaskStore { DefaultPollIntervalMs = 250 }; + +var services = new ServiceCollection(); +services.AddMcpServer() + .WithTools([McpServerTool.Create(SlowTools.RunReport, new() { Name = "run-report" })]) + .WithTasks(store); +services.AddSingleton(new StreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream())); + +await using var serviceProvider = services.BuildServiceProvider(); +var server = serviceProvider.GetRequiredService(); _ = server.RunAsync(); await using McpClient client = await McpClient.CreateAsync( - new StreamClientTransport(clientToServerPipe.Writer.AsStream(), serverToClientPipe.Reader.AsStream())); - -Console.WriteLine("=== CallToolAsync (auto-poll) ==="); -var auto = await client.CallToolAsync( - new CallToolRequestParams { Name = "run-report" }); -Console.WriteLine($" result: {((TextContentBlock)auto.Content[0]).Text}"); -Console.WriteLine(); + new StreamClientTransport( + serverInput: clientToServerPipe.Writer.AsStream(), + serverOutput: serverToClientPipe.Reader.AsStream())); -Console.WriteLine("=== CallToolRawAsync (manual poll) ==="); -var raw = await client.CallToolRawAsync(new CallToolRequestParams { Name = "run-report" }); +Console.WriteLine("=== CallToolAsTaskAsync (manual poll) ==="); +var raw = await client.CallToolAsTaskAsync(new CallToolRequestParams { Name = "run-report" }); if (!raw.IsTask) { - // Either the server doesn't advertise the tasks extension or it chose to run the call - // synchronously despite the client opt-in. Surface the inline result and stop. Console.WriteLine($" result (inline): {((TextContentBlock)raw.Result!.Content[0]).Text}"); return; } @@ -70,7 +64,6 @@ switch (state) { case CompletedTaskResult completed: - // The Result property carries the wrapped CallToolResult as a raw JsonElement. var callToolResult = completed.Result.Deserialize()!; Console.WriteLine($" task completed after {pollCount} poll(s): {((TextContentBlock)callToolResult.Content[0]).Text}"); return; @@ -88,9 +81,6 @@ continue; case InputRequiredTaskResult inputRequired: - // The auto-poll path (CallToolAsync above) routes these through the registered - // ElicitationHandler/SamplingHandler automatically. The manual path needs to call - // UpdateTaskAsync with responses for each outstanding key. Console.WriteLine($" poll {pollCount}: input requested ({inputRequired.InputRequests?.Count ?? 0} key(s))"); continue; } @@ -101,8 +91,6 @@ internal static class SlowTools [Description("Runs a short simulated report and returns when it's done.")] public static async Task RunReport(CancellationToken cancellationToken) { - // Real-world workloads would do meaningful work here; we just sleep so the polling - // path is observable in the console output. await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); return "report ready"; } diff --git a/samples/TasksExtension/TasksExtension.csproj b/samples/TasksExtension/TasksExtension.csproj index 2f66badd7..fe01a8440 100644 --- a/samples/TasksExtension/TasksExtension.csproj +++ b/samples/TasksExtension/TasksExtension.csproj @@ -10,6 +10,8 @@ + + diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index 9ecfe1c12..a96ba5ae0 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -944,25 +944,17 @@ public Task UnsubscribeFromResourceAsync( McpJsonUtilities.JsonContext.Default.EmptyResult, cancellationToken: cancellationToken).AsTask(); } - /// /// Invokes a tool on the server. /// - /// The name of the tool to call on the server. + /// The name of the tool to invoke. /// An optional dictionary of arguments to pass to the tool. - /// An optional progress reporter for server notifications. + /// An optional progress handler for tracking operation progress. /// Optional request options including metadata, serialization settings, and progress tracking. /// The to monitor for cancellation requests. The default is . - /// The from the tool execution. + /// The result of the tool invocation. /// is . /// The request failed or the server returned an error response. - /// - /// This overload supports the tasks extension transparently. If the server responds with a - /// task handle rather than an immediate result, this method polls tasks/get until the - /// task completes, dispatching any entries through - /// the client's registered sampling and elicitation handlers along the way. Use - /// to disable automatic polling. - /// public ValueTask CallToolAsync( string toolName, IReadOnlyDictionary? arguments = null, @@ -1033,221 +1025,19 @@ async ValueTask SendRequestWithProgressAsync( /// The result of the request. /// is . /// The request failed or the server returned an error response. - /// - /// This method automatically includes the io.modelcontextprotocol/tasks extension capability - /// in the request metadata. If the server returns a task handle instead of an immediate result, - /// this method transparently polls tasks/get until the task completes, fails, or is cancelled. - /// Use - /// to receive the raw without automatic polling. - /// - public async ValueTask CallToolAsync( - CallToolRequestParams requestParams, - CancellationToken cancellationToken = default) - { - Throw.IfNull(requestParams); - - var augmented = await CallToolRawAsync(requestParams, cancellationToken).ConfigureAwait(false); - - if (!augmented.IsTask) - { - return augmented.Result!; - } - - return await PollTaskToCompletionAsync(augmented.TaskCreated!, cancellationToken).ConfigureAwait(false); - } - - /// - /// Polls a task until it reaches a terminal state and returns the final . - /// - private async ValueTask PollTaskToCompletionAsync( - CreateTaskResult taskCreated, - CancellationToken cancellationToken) - { - // If the server claims InputRequired but never publishes new input requests after we have - // already responded to everything it asked for, treat that as a stuck task. The client - // can still cancel earlier via cancellationToken; this guard prevents an unbounded poll - // loop when the server is misbehaving. The threshold is configurable via - // McpClientOptions.MaxConsecutiveStuckPolls. - int maxConsecutiveStuckPolls = MaxConsecutiveStuckPolls; - - string taskId = taskCreated.TaskId; - long pollIntervalMs = taskCreated.PollIntervalMs ?? 1000; - HashSet? resolvedRequestKeys = null; - bool isFirstPoll = true; - int consecutiveStuckPolls = 0; - - while (true) - { - // Skip the delay before the first poll: many tasks complete almost immediately and we - // don't want to pay the poll interval as gratuitous latency. - if (!isFirstPoll) - { - await Task.Delay(TimeSpan.FromMilliseconds(pollIntervalMs), cancellationToken).ConfigureAwait(false); - } - isFirstPoll = false; - - var taskResult = await GetTaskAsync(taskId, cancellationToken).ConfigureAwait(false); - - // Update poll interval if the server changed it. - if (taskResult.PollIntervalMs is { } newInterval) - { - pollIntervalMs = newInterval; - } - - switch (taskResult) - { - case CompletedTaskResult completed: - return JsonSerializer.Deserialize(completed.Result, McpJsonUtilities.JsonContext.Default.CallToolResult) - ?? throw new JsonException("Failed to deserialize CallToolResult from completed task."); - - case FailedTaskResult failed: - throw new McpException($"Task '{taskId}' failed: {failed.Error}"); - - case CancelledTaskResult: - throw new OperationCanceledException($"Task '{taskId}' was cancelled by the server."); - - case InputRequiredTaskResult inputRequired: - // Dedup: only resolve input requests we haven't already responded to. - var newRequests = new Dictionary(); - if (inputRequired.InputRequests is { } incomingRequests) - { - foreach (var kvp in incomingRequests) - { - if (resolvedRequestKeys is null || !resolvedRequestKeys.Contains(kvp.Key)) - { - newRequests[kvp.Key] = kvp.Value; - } - } - } - - if (newRequests.Count > 0) - { - consecutiveStuckPolls = 0; - - IDictionary inputResponses; - try - { - inputResponses = await ResolveInputRequestsAsync(newRequests, cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch - { - // The input handler failed (e.g., ElicitationHandler threw or no handler was registered). - // Best-effort cancel of the server-side task so it doesn't stay stuck in InputRequired - // until TTL expires. - try - { - await CancelTaskAsync(taskId, CancellationToken.None).ConfigureAwait(false); - } - catch - { - // Swallow secondary failures; we're already propagating the original exception. - } - - throw; - } - - await UpdateTaskAsync(new UpdateTaskRequestParams - { - TaskId = taskId, - InputResponses = inputResponses, - }, cancellationToken).ConfigureAwait(false); - - resolvedRequestKeys ??= new HashSet(StringComparer.Ordinal); - foreach (var key in inputResponses.Keys) - { - resolvedRequestKeys.Add(key); - } - } - else if (++consecutiveStuckPolls >= maxConsecutiveStuckPolls) - { - // Best-effort cancel of the server-side task so it doesn't leak until TTL expires. - try - { - await CancelTaskAsync(taskId, CancellationToken.None).ConfigureAwait(false); - } - catch - { - // Swallow secondary failures; we're already propagating an exception. - } - - throw new McpException( - $"Task '{taskId}' has remained in '{McpTaskStatus.InputRequired}' for {maxConsecutiveStuckPolls} consecutive polls " + - "without publishing new input requests after all previously requested inputs were resolved."); - } - - break; - - case WorkingTaskResult: - // Continue polling. - consecutiveStuckPolls = 0; - break; - - default: - throw new McpException( - $"Unexpected task result type '{taskResult.GetType().Name}' for task '{taskId}'."); - } - } - } - - /// - /// Invokes a tool on the server with task extension support, returning the raw response - /// without automatic polling. The caller is responsible for handling task lifecycle. - /// - /// The request parameters to send. The tasks extension capability will be injected into the request metadata. - /// The to monitor for cancellation requests. The default is . - /// A that is either an immediate result or a task handle. - /// is . - /// The request failed or the server returned an error response. - /// - /// - /// Unlike , this method does not - /// automatically poll for task completion. If the server returns a , - /// the caller must manage polling via . - /// - /// - public async ValueTask> CallToolRawAsync( + public ValueTask CallToolAsync( CallToolRequestParams requestParams, CancellationToken cancellationToken = default) { Throw.IfNull(requestParams); - var paramsWithMeta = new CallToolRequestParams - { - Name = requestParams.Name, - Arguments = requestParams.Arguments, - // The SEP-2663 Tasks extension requires the 2026-07-28 or later protocol revision. On an older session, send a plain tools/call - // (no task capability envelope) so the server returns a direct CallToolResult and never - // creates a task. - Meta = IsJuly2026OrLaterProtocol() ? GetMetaWithTaskCapability(requestParams.Meta) : requestParams.Meta, - }; - - JsonRpcRequest jsonRpcRequest = new() - { - Method = RequestMethods.ToolsCall, - Params = JsonSerializer.SerializeToNode(paramsWithMeta, McpJsonUtilities.JsonContext.Default.CallToolRequestParams), - }; - - JsonRpcResponse response = await SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false); - - // Discriminate based on resultType field. - if (response.Result is JsonObject resultObj && - resultObj.TryGetPropertyValue("resultType", out var resultTypeNode) && - resultTypeNode?.GetValue() == "task") - { - var taskCreated = resultObj.Deserialize(McpJsonUtilities.JsonContext.Default.CreateTaskResult) - ?? throw new JsonException("Failed to deserialize CreateTaskResult from response."); - return new ResultOrCreatedTask(taskCreated); - } - - var callToolResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.JsonContext.Default.CallToolResult) - ?? throw new JsonException("Failed to deserialize CallToolResult from response."); - return new ResultOrCreatedTask(callToolResult); + return SendRequestAsync( + RequestMethods.ToolsCall, + requestParams, + McpJsonUtilities.JsonContext.Default.CallToolRequestParams, + McpJsonUtilities.JsonContext.Default.CallToolResult, + cancellationToken: cancellationToken); } - /// /// Sets the logging level for the server to control which log messages are sent to the client. /// @@ -1302,111 +1092,6 @@ public Task SetLoggingLevelAsync( McpJsonUtilities.JsonContext.Default.EmptyResult, cancellationToken: cancellationToken).AsTask(); } - - /// - /// Retrieves the current state of a task from the server. - /// - /// The stable identifier of the task to retrieve. - /// The to monitor for cancellation requests. The default is . - /// A subtype representing the current task state. - /// is . - /// The request failed or the server returned an error response. - public ValueTask GetTaskAsync( - string taskId, - CancellationToken cancellationToken = default) - { - Throw.IfNull(taskId); - - return GetTaskAsync(new GetTaskRequestParams { TaskId = taskId }, cancellationToken); - } - - /// - /// Retrieves the current state of a task from the server. - /// - /// The request parameters to send in the request. - /// The to monitor for cancellation requests. The default is . - /// A subtype representing the current task state. - /// is . - /// The request failed or the server returned an error response. - public ValueTask GetTaskAsync( - GetTaskRequestParams requestParams, - CancellationToken cancellationToken = default) - { - Throw.IfNull(requestParams); - ThrowIfTasksNotSupported(nameof(GetTaskAsync)); - - return SendRequestAsync( - RequestMethods.TasksGet, - requestParams, - McpJsonUtilities.JsonContext.Default.GetTaskRequestParams, - McpJsonUtilities.JsonContext.Default.GetTaskResult, - cancellationToken: cancellationToken); - } - - /// - /// Provides input responses to a task that is in the state. - /// - /// The request parameters containing the task ID and input responses. - /// The to monitor for cancellation requests. The default is . - /// The result acknowledging the update. - /// is . - /// The request failed or the server returned an error response. - public ValueTask UpdateTaskAsync( - UpdateTaskRequestParams requestParams, - CancellationToken cancellationToken = default) - { - Throw.IfNull(requestParams); - ThrowIfTasksNotSupported(nameof(UpdateTaskAsync)); - - return SendRequestAsync( - RequestMethods.TasksUpdate, - requestParams, - McpJsonUtilities.JsonContext.Default.UpdateTaskRequestParams, - McpJsonUtilities.JsonContext.Default.UpdateTaskResult, - cancellationToken: cancellationToken); - } - - /// - /// Requests cancellation of an in-progress task on the server. - /// - /// The stable identifier of the task to cancel. - /// The to monitor for cancellation requests. The default is . - /// The result acknowledging the cancellation request. - /// is . - /// The request failed or the server returned an error response. - public ValueTask CancelTaskAsync( - string taskId, - CancellationToken cancellationToken = default) - { - Throw.IfNull(taskId); - - return CancelTaskAsync(new CancelTaskRequestParams { TaskId = taskId }, cancellationToken); - } - - /// - /// Requests cancellation of an in-progress task on the server. - /// - /// The request parameters to send in the request. - /// The to monitor for cancellation requests. The default is . - /// The result acknowledging the cancellation request. - /// is . - /// The request failed or the server returned an error response. - public ValueTask CancelTaskAsync( - CancelTaskRequestParams requestParams, - CancellationToken cancellationToken = default) - { - Throw.IfNull(requestParams); - ThrowIfTasksNotSupported(nameof(CancelTaskAsync)); - - return SendRequestAsync( - RequestMethods.TasksCancel, - requestParams, - McpJsonUtilities.JsonContext.Default.CancelTaskRequestParams, - McpJsonUtilities.JsonContext.Default.CancelTaskResult, - cancellationToken: cancellationToken); - } - - /// Converts a dictionary with values to a dictionary with values. private static Dictionary? ToArgumentsDictionary( IReadOnlyDictionary? arguments, JsonSerializerOptions options) { @@ -1425,43 +1110,4 @@ public ValueTask CancelTaskAsync( return result; } - // Per SEP-2663 §51, the per-request opt-in uses the SEP-2575 capabilities envelope: - // _meta/io.modelcontextprotocol/clientCapabilities/extensions/io.modelcontextprotocol/tasks = {} - private static JsonObject GetMetaWithTaskCapability(JsonObject? existingMeta) - { - JsonObject meta = existingMeta is not null - ? (JsonObject)existingMeta.DeepClone() - : []; - - if (meta[MetaKeys.ClientCapabilities] is not JsonObject capsRoot) - { - capsRoot = []; - meta[MetaKeys.ClientCapabilities] = capsRoot; - } - - if (capsRoot["extensions"] is not JsonObject extensionsRoot) - { - extensionsRoot = []; - capsRoot["extensions"] = extensionsRoot; - } - - extensionsRoot.TryAdd(McpExtensions.Tasks, new JsonObject()); - return meta; - } - - /// - /// Throws when the negotiated protocol version does not support the SEP-2663 Tasks extension. Tasks - /// require the 2026-07-28 or later protocol revision, and a task id only ever exists when the session - /// negotiated such a revision, so invoking tasks/get, tasks/update, or tasks/cancel - /// on an older session is a programming error rather than a recoverable protocol condition. - /// - private void ThrowIfTasksNotSupported(string operationName) - { - if (!IsJuly2026OrLaterProtocol()) - { - throw new InvalidOperationException( - $"'{operationName}' requires a newer protocol revision that supports tasks. " + - $"The negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'."); - } - } } diff --git a/src/ModelContextProtocol.Core/Client/McpClient.cs b/src/ModelContextProtocol.Core/Client/McpClient.cs index eaa27d70e..d683172da 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.cs @@ -73,7 +73,7 @@ protected McpClient() public abstract Task Completion { get; } /// - /// Resolves input requests embedded in an by dispatching + /// Resolves input requests by dispatching /// each request to the appropriate registered handler. /// /// @@ -82,17 +82,9 @@ protected McpClient() /// /// The to monitor for cancellation requests. /// A dictionary of responses keyed by the same identifiers as the input requests. - [Experimental(Experimentals.Extensions_DiagnosticId, UrlFormat = Experimentals.Extensions_Url)] public abstract ValueTask> ResolveInputRequestsAsync( IDictionary inputRequests, CancellationToken cancellationToken); - /// - /// Gets the maximum number of consecutive stuck-in- polls - /// allowed by before the client cancels and throws. - /// Sourced from . - /// - private protected abstract int MaxConsecutiveStuckPolls { get; } - /// /// Inspects a received cacheable result (tools/list, prompts/list, resources/list, /// resources/templates/list, or resources/read) so derived clients can emit diagnostics. diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index d5aa44709..882f97bb2 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -179,7 +179,6 @@ private void RegisterHandlers(McpClientOptions options, NotificationHandlers not public override Task Completion => _sessionHandler.CompletionTask; /// - private protected override int MaxConsecutiveStuckPolls => _options.MaxConsecutiveStuckPolls; /// public override async ValueTask> ResolveInputRequestsAsync( diff --git a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs index aaf3cefa6..510934cd8 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs @@ -147,41 +147,4 @@ public McpClientHandlers Handlers } } - /// - /// Gets or sets the maximum number of consecutive task polls during which a task may report - /// without publishing any new input requests, before - /// the client treats the task as stuck, issues a best-effort tasks/cancel, and throws - /// an . - /// - /// - /// The maximum number of consecutive stuck polls allowed. The default value is 60. - /// - /// - /// - /// This guard prevents an unbounded poll loop when the server keeps a task in - /// but never publishes new input requests after the - /// client has already responded to every previously surfaced request. It only affects the - /// long-poll path used by ; - /// it does not affect direct calls. - /// - /// - /// Callers should size this value with the configured server-side poll interval in mind: the - /// effective wall-clock timeout is roughly MaxConsecutiveStuckPolls * pollIntervalMs. - /// Setting this to a very small value can cause false positives for servers that are slow to - /// surface follow-up input requests; setting it too large can mask misbehaving servers. - /// - /// - /// The value is less than 1. - public int MaxConsecutiveStuckPolls - { - get; - set - { - if (value < 1) - { - throw new ArgumentOutOfRangeException(nameof(value), value, "must be greater than or equal to 1."); - } - field = value; - } - } = 60; } diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index da1b898dd..b193bb8de 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -54,7 +54,13 @@ private static JsonSerializerOptions CreateDefaultOptions() return options; } - internal static JsonTypeInfo GetTypeInfo(this JsonSerializerOptions options) => + /// + /// Gets the resolved for from the specified options. + /// + /// The type whose serialization metadata should be resolved. + /// The serializer options providing the type-info resolver chain. + /// The resolved . + public static JsonTypeInfo GetTypeInfo(this JsonSerializerOptions options) => (JsonTypeInfo)options.GetTypeInfo(typeof(T)); internal static JsonElement DefaultMcpToolSchema { get; } = ParseJsonElement("""{"type":"object"}"""u8); @@ -120,12 +126,6 @@ internal static bool IsValidToolOutputSchema(JsonElement element) => [JsonSerializable(typeof(ResourceUpdatedNotificationParams))] [JsonSerializable(typeof(RootsListChangedNotificationParams))] [JsonSerializable(typeof(ToolListChangedNotificationParams))] - [JsonSerializable(typeof(TaskStatusNotificationParams))] - [JsonSerializable(typeof(WorkingTaskNotificationParams))] - [JsonSerializable(typeof(CompletedTaskNotificationParams))] - [JsonSerializable(typeof(FailedTaskNotificationParams))] - [JsonSerializable(typeof(CancelledTaskNotificationParams))] - [JsonSerializable(typeof(InputRequiredTaskNotificationParams))] // MCP Request Params / Results [JsonSerializable(typeof(CallToolRequestParams))] @@ -174,19 +174,6 @@ internal static bool IsValidToolOutputSchema(JsonElement element) => [JsonSerializable(typeof(IDictionary))] [JsonSerializable(typeof(IDictionary))] - [JsonSerializable(typeof(GetTaskRequestParams))] - [JsonSerializable(typeof(GetTaskResult))] - [JsonSerializable(typeof(WorkingTaskResult))] - [JsonSerializable(typeof(CompletedTaskResult))] - [JsonSerializable(typeof(FailedTaskResult))] - [JsonSerializable(typeof(CancelledTaskResult))] - [JsonSerializable(typeof(InputRequiredTaskResult))] - [JsonSerializable(typeof(UpdateTaskRequestParams))] - [JsonSerializable(typeof(UpdateTaskResult))] - [JsonSerializable(typeof(CancelTaskRequestParams))] - [JsonSerializable(typeof(CancelTaskResult))] - [JsonSerializable(typeof(CreateTaskResult))] - // MCP Content [JsonSerializable(typeof(ContentBlock))] [JsonSerializable(typeof(TextContentBlock))] diff --git a/src/ModelContextProtocol.Core/Protocol/McpExtensions.cs b/src/ModelContextProtocol.Core/Protocol/McpExtensions.cs deleted file mode 100644 index a41e4a576..000000000 --- a/src/ModelContextProtocol.Core/Protocol/McpExtensions.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace ModelContextProtocol.Protocol; - -/// -/// Provides constants for well-known MCP extension identifiers. -/// -public static class McpExtensions -{ - /// - /// The extension identifier for the MCP Tasks extension. - /// - /// - /// When included in client per-request capabilities, indicates the client can handle - /// in lieu of a standard result. - /// See the SEP-2663 - /// specification for details. - /// - public const string Tasks = "io.modelcontextprotocol/tasks"; -} diff --git a/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs b/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs index 4d9139953..29db6f5ae 100644 --- a/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs +++ b/src/ModelContextProtocol.Core/Protocol/MetaKeys.cs @@ -53,24 +53,4 @@ public static class MetaKeys /// belonging to different subscriptions on a shared channel (especially STDIO). /// public const string SubscriptionId = "io.modelcontextprotocol/subscriptionId"; - - /// - /// The metadata key used to associate requests, responses, and notifications with a task. - /// - /// - /// - /// This constant defines the key "io.modelcontextprotocol/related-task" used in the - /// _meta field to associate messages with their originating task across the entire - /// request lifecycle. - /// - /// - /// For example, an elicitation that a task-augmented tool call depends on must share the - /// same related task ID with that tool call's task. - /// - /// - /// For tasks/get, tasks/list, and tasks/cancel operations, this - /// metadata should not be included as the taskId is already present in the message structure. - /// - /// - public const string RelatedTask = "io.modelcontextprotocol/related-task"; } diff --git a/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs b/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs index 0bd3348f8..7911d9e33 100644 --- a/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs +++ b/src/ModelContextProtocol.Core/Protocol/NotificationMethods.cs @@ -144,16 +144,6 @@ public static class NotificationMethods /// public const string CancelledNotification = "notifications/cancelled"; - /// - /// The name of the notification sent by the server to push task status updates to subscribed clients. - /// - /// - /// Part of the io.modelcontextprotocol/tasks extension. - /// Each notification carries a complete task state for the current status, identical to what - /// tasks/get would have returned at that moment. - /// - public const string TaskStatusNotification = "notifications/tasks/status"; - /// /// The name of the notification sent first on a /// response stream to indicate which notification types the server agreed to deliver. diff --git a/src/ModelContextProtocol.Core/Protocol/NotificationParams.cs b/src/ModelContextProtocol.Core/Protocol/NotificationParams.cs index 54432a4c2..b13a746cd 100644 --- a/src/ModelContextProtocol.Core/Protocol/NotificationParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/NotificationParams.cs @@ -8,8 +8,8 @@ namespace ModelContextProtocol.Protocol; /// public abstract class NotificationParams { - /// Prevent external derivations. - private protected NotificationParams() + /// Initializes the base notification parameter type. + protected NotificationParams() { } diff --git a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs index a2884efba..29d7d3764 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs @@ -125,33 +125,6 @@ public static class RequestMethods /// public const string Initialize = "initialize"; - /// - /// The name of the request method sent from the client to poll for task completion. - /// - /// - /// Part of the io.modelcontextprotocol/tasks extension. - /// Clients poll for task status by sending this request with the task ID. - /// - public const string TasksGet = "tasks/get"; - - /// - /// The name of the request method sent from the client to provide input responses to a task. - /// - /// - /// Part of the io.modelcontextprotocol/tasks extension. - /// Used when a task has input_required status and the client needs to fulfill outstanding requests. - /// - public const string TasksUpdate = "tasks/update"; - - /// - /// The name of the request method sent from the client to signal intent to cancel a task. - /// - /// - /// Part of the io.modelcontextprotocol/tasks extension. - /// Cancellation is cooperative — the server decides whether and when to honor it. - /// - public const string TasksCancel = "tasks/cancel"; - /// /// The name of the request method sent from the client to discover the server's protocol versions, /// capabilities, and metadata. diff --git a/src/ModelContextProtocol.Core/Protocol/RequestParams.cs b/src/ModelContextProtocol.Core/Protocol/RequestParams.cs index 37872ec5b..04c3d82ef 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestParams.cs @@ -11,8 +11,8 @@ namespace ModelContextProtocol.Protocol; /// public abstract class RequestParams { - /// Prevent external derivations. - private protected RequestParams() + /// Initializes the base request parameter type. + protected RequestParams() { } diff --git a/src/ModelContextProtocol.Core/Protocol/Result.cs b/src/ModelContextProtocol.Core/Protocol/Result.cs index 15eb6fa46..e64be9728 100644 --- a/src/ModelContextProtocol.Core/Protocol/Result.cs +++ b/src/ModelContextProtocol.Core/Protocol/Result.cs @@ -8,8 +8,8 @@ namespace ModelContextProtocol.Protocol; /// public abstract class Result { - /// Prevent external derivations. - private protected Result() + /// Initializes the base result type. + protected Result() { } @@ -28,10 +28,8 @@ private protected Result() /// /// /// When absent or set to "complete", the result is a normal completed response. - /// When set to "input_required", the result is an indicating - /// that additional input is needed before the request can be completed. - /// When set to "task", the result is a indicating that the server - /// has created a long-running task in lieu of returning the result directly. + /// Other values discriminate alternate result subtypes so callers can choose the appropriate + /// concrete payload to deserialize. /// /// /// Defaults to , which is equivalent to "complete". diff --git a/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs b/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs index 72ba2e67d..e2e1f112e 100644 --- a/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs +++ b/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs @@ -75,12 +75,4 @@ public ResultOrAlternate(Result alternate, JsonTypeInfo alternateTypeInfo) /// /// The result to wrap. public static implicit operator ResultOrAlternate(TResult result) => new(result); - - /// - /// Implicitly converts a to a - /// wrapping the task handle as an alternate result using the default serialization context. - /// - /// The task creation result to wrap. - public static implicit operator ResultOrAlternate(CreateTaskResult taskCreated) => - new(taskCreated, McpJsonUtilities.JsonContext.Default.CreateTaskResult); } diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs index 657f58ba0..d6cb83c70 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs @@ -107,11 +107,6 @@ public static McpServer Create( /// , which is always open for the duration of /// the request, rather than relying on the optional standalone GET SSE stream. /// - /// - /// When called during task-augmented tool execution, this method automatically updates the task - /// status to while waiting for the client response, - /// then returns to when the response is received. - /// /// [Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public ValueTask SampleAsync( @@ -366,11 +361,6 @@ public ValueTask RequestRootsAsync( /// , which is always open for the duration of /// the request, rather than relying on the optional standalone GET SSE stream. /// - /// - /// When called during task-augmented tool execution, this method automatically updates the task - /// status to while waiting for user input, - /// then returns to when the response is received. - /// /// public async ValueTask ElicitAsync( ElicitRequestParams requestParams, @@ -463,26 +453,6 @@ public async ValueTask> ElicitAsync( return new ElicitResult { Action = raw.Action, Content = typed }; } - /// - /// Sends a task status notification to the connected client. - /// - /// The task status notification parameters to send. - /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous send operation. - /// is . - public Task SendTaskStatusNotificationAsync( - TaskStatusNotificationParams notificationParams, - CancellationToken cancellationToken = default) - { - Throw.IfNull(notificationParams); - - return SendNotificationAsync( - NotificationMethods.TaskStatusNotification, - notificationParams, - McpJsonUtilities.JsonContext.Default.TaskStatusNotificationParams, - cancellationToken); - } - /// /// Builds a request schema for elicitation based on the public serializable properties of . /// @@ -632,63 +602,6 @@ private void ThrowIfRootsUnsupported() } } - /// - /// Creates a scope that redirects server-initiated requests (elicitation, sampling, list roots) through - /// the task store as input requests for the duration of the scope. Use this when executing tool logic - /// in the background as a task, so that any server-to-client requests are surfaced to the client via - /// the task's state instead of direct JSON-RPC messages. - /// - /// The task ID in the store. - /// The task store to write input requests to. - /// An that restores the previous context when disposed. - public IDisposable CreateMcpTaskScope( - string taskId, - IMcpTaskStore store) - { - Throw.IfNull(taskId); - Throw.IfNull(store); - - return InterceptOutgoingRequests(async (method, paramsNode, cancellationToken) => - { - var requestId = Guid.NewGuid().ToString("N"); - - var inputRequest = new InputRequest - { - Method = method, - Params = paramsNode is null - ? default - : JsonSerializer.SerializeToElement(paramsNode, McpJsonUtilities.DefaultOptions.GetTypeInfo()), - }; - - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - void handler(InputResponseReceivedEventArgs args) - { - if (args.TaskId == taskId && args.RequestId == requestId) - { - tcs.TrySetResult(args.Response); - } - } - - store.InputResponseReceived += handler; - try - { - await store.SetInputRequestsAsync( - taskId, - new Dictionary { [requestId] = inputRequest }, - cancellationToken).ConfigureAwait(false); - - var response = await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); - - return JsonNode.Parse(response.RawValue.GetRawText()); - } - finally - { - store.InputResponseReceived -= handler; - } - }); - } - /// /// Sends a server-initiated request through the installed outgoing-request interceptor, then awaits the response. /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs b/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs index 3aa1fdea6..37d2a3d73 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs @@ -43,7 +43,7 @@ public sealed class McpServerHandlers /// This handler is invoked when a client makes a call to a tool that isn't found in the collection. /// The handler should implement logic to execute the requested tool and return appropriate results. /// Use instead if the tool may return an alternate result - /// (such as ) for asynchronous execution. + /// for the caller to handle. /// /// is already set. public McpRequestHandler? CallToolHandler @@ -67,8 +67,7 @@ public McpRequestHandler? CallToolHandler /// /// /// This handler is invoked when a client makes a call to a tool, allowing the tool to return either - /// a for immediate results or an alternate subtype - /// (such as ) for long-running asynchronous operations. + /// a for immediate results or an alternate subtype. /// /// /// Cannot be set if is already set. @@ -202,74 +201,6 @@ public McpRequestHandler? SetLoggingLevelHandler { get; set; } - /// - /// Gets or sets the handler for requests. - /// - /// - /// - /// This handler is invoked when a client polls for the current state of a task. - /// The handler should return the appropriate subtype - /// based on the task's current status (for example, , - /// , , - /// , or ). - /// - /// - /// Setting is the recommended way to wire all three - /// task lifecycle handlers (, , - /// and ) from a single source while still allowing explicit - /// handlers to override any slot. If can return a - /// but no is configured (either - /// directly or via a task store), the server throws - /// when processing the request so misconfigured deployments fail loudly instead of producing - /// unpollable tasks. - /// - /// - public McpRequestHandler? GetTaskHandler { get; set; } - - /// - /// Gets or sets the handler for requests. - /// - /// - /// - /// This handler is invoked when a client provides input responses for a task - /// that is in the state. Responses keyed - /// by an identifier that does not currently correspond to an outstanding input request - /// (including responses for tasks in a terminal state) should be silently ignored per - /// SEP-2663. - /// - /// - /// Prefer configuring instead of setting this - /// handler directly; the default implementation built from the store dispatches to - /// and raises - /// to wake any pending - /// - /// or - /// calls executing inside a task scope. - /// - /// - public McpRequestHandler? UpdateTaskHandler { get; set; } - - /// - /// Gets or sets the handler for requests. - /// - /// - /// - /// This handler is invoked when a client requests cancellation of an in-progress task. - /// Per SEP-2663, cancellation is cooperative and eventually consistent: the handler should - /// always acknowledge the request with , even if the task is - /// unknown, already terminal, or cannot actually be stopped. Whether the task transitions - /// to is up to the implementation. - /// - /// - /// Prefer configuring instead of setting this - /// handler directly; the default implementation built from the store calls - /// and signals the per-task - /// so the tool's - /// observes cancellation. - /// - /// - public McpRequestHandler? CancelTaskHandler { get; set; } - /// Gets or sets notification handlers to register with the server. /// /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 11ba56734..f4e1c52cc 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -28,7 +28,6 @@ internal sealed partial class McpServerImpl : McpServer private readonly RequestHandlers _requestHandlers; private readonly McpSessionHandler _sessionHandler; private readonly SemaphoreSlim _disposeLock = new(1, 1); - private readonly ConcurrentDictionary _taskCancellationSources = new(); private readonly ConcurrentDictionary _mrtrContinuations = new(); private readonly ConcurrentDictionary _mrtrContextsByRequestId = new(); @@ -95,7 +94,6 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact ConfigureCompletion(options); ConfigureSubscriptions(options); ConfigureExperimentalAndExtensions(options); - ConfigureTasks(options); ConfigureMrtr(); ConfigureCustomRequestHandlers(options); @@ -313,13 +311,6 @@ public override async ValueTask DisposeAsync() _disposed = true; - foreach (var kvp in _taskCancellationSources) - { - kvp.Value.Cancel(); - kvp.Value.Dispose(); - } - _taskCancellationSources.Clear(); - // Dispose the session handler - cancels message processing and waits for all // in-flight request handlers (including retries in AwaitMrtrHandlerAsync) to complete. // After this returns, no new requests can be processed and no new MRTR continuations @@ -754,112 +745,6 @@ private void ConfigureCompletion(McpServerOptions options) 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)); - - // The tasks/* methods do not exist before the 2026-07-28 revision (SEP-2663). Reject them with - // MethodNotFound when the request was negotiated under a legacy protocol version. The handlers - // stay registered so a dual-era server still serves them for 2026-07-28 requests. - getTaskHandler = GateTaskMethodToJuly2026OrLaterProtocol(getTaskHandler, RequestMethods.TasksGet); - updateTaskHandler = GateTaskMethodToJuly2026OrLaterProtocol(updateTaskHandler, RequestMethods.TasksUpdate); - cancelTaskHandler = GateTaskMethodToJuly2026OrLaterProtocol(cancelTaskHandler, RequestMethods.TasksCancel); - - // Advertise tasks extension in server capabilities. - ServerCapabilities.Extensions ??= new Dictionary(); - ServerCapabilities.Extensions[McpExtensions.Tasks] = new JsonObject(); - - SetHandler( - RequestMethods.TasksGet, - getTaskHandler, - McpJsonUtilities.JsonContext.Default.GetTaskRequestParams, - McpJsonUtilities.JsonContext.Default.GetTaskResult); - - SetHandler( - RequestMethods.TasksUpdate, - updateTaskHandler, - McpJsonUtilities.JsonContext.Default.UpdateTaskRequestParams, - McpJsonUtilities.JsonContext.Default.UpdateTaskResult); - - SetHandler( - RequestMethods.TasksCancel, - cancelTaskHandler, - McpJsonUtilities.JsonContext.Default.CancelTaskRequestParams, - McpJsonUtilities.JsonContext.Default.CancelTaskResult); - } - - /// - /// Wraps a tasks/* request handler so it throws unless the - /// request was negotiated under the 2026-07-28 or later revision. The tasks extension (SEP-2663) only - /// interoperates on the 2026-07-28 revision, and these methods don't exist on older peers. - /// - private McpRequestHandler GateTaskMethodToJuly2026OrLaterProtocol( - McpRequestHandler inner, string method) - => (request, cancellationToken) => - { - if (!IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest)) - { - throw new McpProtocolException( - $"The method '{method}' requires a newer protocol revision that supports tasks; " + - $"the negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'.", - McpErrorCode.MethodNotFound); - } - - return inner(request, cancellationToken); - }; - private void ConfigureExperimentalAndExtensions(McpServerOptions options) { ServerCapabilities.Experimental = options.Capabilities?.Experimental; @@ -1273,111 +1158,6 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) callToolWithAlternateHandler = async (request, cancellationToken) => await finalCallToolHandler(request, cancellationToken).ConfigureAwait(false); } - - // If a task store is configured, wrap so that when the client signals task support - // the tool execution is offloaded to the background via the store. - if (options.TaskStore is { } taskStore) - { - var innerAlternateHandler = callToolWithAlternateHandler; - callToolWithAlternateHandler = async (request, cancellationToken) => - { - // The SEP-2663 Tasks extension requires the 2026-07-28 or later revision: the task wire shapes we ship do not - // interoperate with legacy (<= 2025-11-25) peers. Only materialize a task when the - // request was negotiated under the 2026-07-28 or later revision AND the client opted in; otherwise - // run the inner handler and return the direct result (best-effort downgrade, which also - // defends against a non-conformant legacy client that forges the opt-in envelope). - if (IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest) && HasTaskExtensionOptIn(request.Params?.Meta)) - { - var taskInfo = await taskStore.CreateTaskAsync(cancellationToken).ConfigureAwait(false); - var taskId = taskInfo.TaskId; - - var cts = new CancellationTokenSource(); - _taskCancellationSources[taskId] = cts; - - // Capture the token synchronously before Task.Run dispatches the work. - // The cancel handler may race with the background runner: whichever side wins - // the TryRemove call owns disposal. If we accessed cts.Token from inside the - // lambda after the handler had already disposed cts, we'd hit ObjectDisposedException. - var taskCancellationToken = cts.Token; - - _ = Task.Run(async () => - { - using (CreateMcpTaskScope(taskId, taskStore)) - { - try - { - var augmented = await innerAlternateHandler(request, taskCancellationToken).ConfigureAwait(false); - if (augmented.IsAlternate) - { - // 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.CallToolWithAlternateHandler)} returned IsAlternate = 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.CallToolWithAlternateHandler)} 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 new ResultOrAlternate( - ToCreateTaskResult(taskInfo), - McpJsonUtilities.JsonContext.Default.CreateTaskResult); - } - - return await innerAlternateHandler(request, cancellationToken).ConfigureAwait(false); - }; - } - ServerCapabilities.Tools.ListChanged = listChanged; SetHandler( @@ -1392,86 +1172,6 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) McpJsonUtilities.JsonContext.Default.CallToolRequestParams, McpJsonUtilities.JsonContext.Default.CallToolResult); } - - private static CreateTaskResult ToCreateTaskResult(McpTaskInfo info) => new() - { - TaskId = info.TaskId, - Status = info.Status, - CreatedAt = info.CreatedAt, - LastUpdatedAt = info.LastUpdatedAt, - TimeToLive = info.TimeToLive, - PollIntervalMs = info.PollIntervalMs, - StatusMessage = info.StatusMessage, - ResultType = "task", - }; - - private static GetTaskResult ToGetTaskResult(McpTaskInfo info) => info.Status switch - { - McpTaskStatus.Working => new WorkingTaskResult - { - TaskId = info.TaskId, - CreatedAt = info.CreatedAt, - LastUpdatedAt = info.LastUpdatedAt, - TimeToLive = info.TimeToLive, - PollIntervalMs = info.PollIntervalMs, - StatusMessage = info.StatusMessage, - ResultType = "complete", - }, - McpTaskStatus.Completed => new CompletedTaskResult - { - TaskId = info.TaskId, - CreatedAt = info.CreatedAt, - LastUpdatedAt = info.LastUpdatedAt, - TimeToLive = info.TimeToLive, - PollIntervalMs = info.PollIntervalMs, - StatusMessage = info.StatusMessage, - Result = info.Result ?? throw new InvalidOperationException($"Task '{info.TaskId}' is completed but has no result."), - ResultType = "complete", - }, - McpTaskStatus.Failed => new FailedTaskResult - { - TaskId = info.TaskId, - CreatedAt = info.CreatedAt, - LastUpdatedAt = info.LastUpdatedAt, - TimeToLive = info.TimeToLive, - PollIntervalMs = info.PollIntervalMs, - StatusMessage = info.StatusMessage, - Error = info.Error ?? throw new InvalidOperationException($"Task '{info.TaskId}' is failed but has no error."), - ResultType = "complete", - }, - McpTaskStatus.Cancelled => new CancelledTaskResult - { - TaskId = info.TaskId, - CreatedAt = info.CreatedAt, - LastUpdatedAt = info.LastUpdatedAt, - TimeToLive = info.TimeToLive, - PollIntervalMs = info.PollIntervalMs, - StatusMessage = info.StatusMessage, - ResultType = "complete", - }, - McpTaskStatus.InputRequired => new InputRequiredTaskResult - { - TaskId = info.TaskId, - CreatedAt = info.CreatedAt, - LastUpdatedAt = info.LastUpdatedAt, - TimeToLive = info.TimeToLive, - PollIntervalMs = info.PollIntervalMs, - StatusMessage = info.StatusMessage, - // McpTaskInfo.InputRequests is IReadOnlyDictionary (covers immutable store - // implementations like InMemoryMcpTaskStore's ImmutableDictionary), while the wire - // DTO uses IDictionary like every other Protocol type. Most concrete stores back - // their dictionaries with a type that implements both interfaces (Dictionary, - // ImmutableDictionary, ConcurrentDictionary), so the cast usually succeeds and we - // only allocate a copy as a fallback. - InputRequests = info.InputRequests is IDictionary dict - ? dict - : info.InputRequests?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) - ?? new Dictionary(), - ResultType = "complete", - }, - _ => throw new InvalidOperationException($"Unknown task status: {info.Status}"), - }; - private static async ValueTask> InvokeToolWithAlternate( McpServerTool tool, RequestContext request, @@ -1725,15 +1425,6 @@ private static McpRequestHandler BuildFilterPipeline - meta is not null && - meta[MetaKeys.ClientCapabilities] is JsonObject caps && - caps["extensions"] is JsonObject exts && - exts.ContainsKey(McpExtensions.Tasks); - private JsonRpcMessageFilter BuildMessageFilterPipeline(IList filters) { if (filters.Count == 0) @@ -1808,12 +1499,10 @@ internal static LoggingLevel ToLoggingLevel(LogLevel level) => /// internal bool HasStatefulTransport() => _sessionTransport is not StreamableHttpServerTransport { Stateless: true }; - /// /// Returns when the given request was negotiated under the 2026-07-28 or later protocol /// revision, derived from the per-request _meta/MCP-Protocol-Version value (so it works /// for requests over stateless HTTP) and falling back to the session-negotiated version. - /// Used to gate the SEP-2663 Tasks extension, which only interoperates on the 2026-07-28 revision. /// private bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request) => McpHttpHeaders.IsJuly2026OrLaterProtocolVersion( diff --git a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs index d31d7882b..ad3758151 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs @@ -190,21 +190,6 @@ public McpServerFilters Filters [Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public int MaxSamplingOutputTokens { get; set; } = 1000; - /// - /// Gets or sets the task store for managing asynchronous task executions. - /// - /// - /// - /// When set, the server automatically enables the io.modelcontextprotocol/tasks extension - /// and wires up tasks/get, tasks/update, and tasks/cancel handlers backed by this store. - /// Tool executions from clients that signal task support will be wrapped in tasks via the store. - /// - /// - /// If explicit task handlers are also set on , the explicit handlers take precedence. - /// - /// - public IMcpTaskStore? TaskStore { get; set; } - /// /// Gets or sets custom request handlers to register with the server. /// diff --git a/src/ModelContextProtocol.Core/Server/McpTaskExecutionContext.cs b/src/ModelContextProtocol.Core/Server/McpTaskExecutionContext.cs deleted file mode 100644 index 749f36517..000000000 --- a/src/ModelContextProtocol.Core/Server/McpTaskExecutionContext.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace ModelContextProtocol.Server; - -/// -/// Provides ambient context when a tool is executing as a background task. -/// When established, calls to , -/// , -/// and -/// are redirected through the task store as input requests rather than sent directly to the client. -/// -internal sealed class McpTaskExecutionContext -{ - internal static readonly AsyncLocal Current = new(); - - public required string TaskId { get; init; } - public required IMcpTaskStore Store { get; init; } - - internal sealed class Scope(McpTaskExecutionContext? previous) : IDisposable - { - public void Dispose() => Current.Value = previous; - } -} diff --git a/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs new file mode 100644 index 000000000..43dd0c2ee --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/Client/McpTasksClientExtensions.cs @@ -0,0 +1,370 @@ +using ModelContextProtocol; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Extension methods for task-aware client operations. +/// +public static class McpTasksClientExtensions +{ + /// + /// Calls a tool and returns either an immediate result or a created task. + /// + public static async ValueTask> CallToolAsTaskAsync( + this McpClient client, + CallToolRequestParams requestParams, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestParams); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); +#endif + + var paramsWithMeta = new CallToolRequestParams + { + Name = requestParams.Name, + Arguments = requestParams.Arguments, + Meta = IsJuly2026OrLaterProtocol(client) ? GetMetaWithTaskCapability(requestParams.Meta) : requestParams.Meta, + }; + + JsonRpcRequest jsonRpcRequest = new() + { + Method = RequestMethods.ToolsCall, + Params = JsonSerializer.SerializeToNode(paramsWithMeta, McpJsonUtilities.DefaultOptions.GetTypeInfo()), + }; + + JsonRpcResponse response = await client.SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false); + + if (response.Result is JsonObject resultObj && + resultObj.TryGetPropertyValue("resultType", out var resultTypeNode) && + string.Equals(resultTypeNode?.GetValue(), "task", StringComparison.Ordinal)) + { + var taskCreated = resultObj.Deserialize(McpTasksJsonContext.Default.CreateTaskResult) + ?? throw new JsonException("Failed to deserialize CreateTaskResult from response."); + return new ResultOrCreatedTask(taskCreated); + } + + var callToolResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions.GetTypeInfo()) + ?? throw new JsonException("Failed to deserialize CallToolResult from response."); + return new ResultOrCreatedTask(callToolResult); + } + + /// + /// Calls a tool and, if needed, polls the created task to completion. + /// + public static async ValueTask CallToolWithPollingAsync( + this McpClient client, + CallToolRequestParams requestParams, + int maxConsecutiveStuckPolls = 60, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestParams); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); +#endif + + var augmented = await client.CallToolAsTaskAsync(requestParams, cancellationToken).ConfigureAwait(false); + if (!augmented.IsTask) + { + return augmented.Result!; + } + + return await PollTaskToCompletionAsync(client, augmented.TaskCreated!, maxConsecutiveStuckPolls, cancellationToken).ConfigureAwait(false); + } + + /// + /// Retrieves a task by ID. + /// + public static ValueTask GetTaskAsync( + this McpClient client, + string taskId, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(taskId); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (taskId is null) throw new ArgumentNullException(nameof(taskId)); +#endif + + return client.GetTaskAsync(new GetTaskRequestParams { TaskId = taskId }, cancellationToken); + } + + /// + /// Retrieves a task using explicit request parameters. + /// + public static ValueTask GetTaskAsync( + this McpClient client, + GetTaskRequestParams requestParams, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestParams); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); +#endif + + ThrowIfTasksNotSupported(client, nameof(GetTaskAsync)); + return client.SendRequestAsync( + TasksProtocol.MethodTasksGet, + requestParams, + McpTasksJsonContext.Default.Options, + cancellationToken: cancellationToken); + } + + /// + /// Updates a task with input responses. + /// + public static async ValueTask UpdateTaskAsync( + this McpClient client, + UpdateTaskRequestParams requestParams, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestParams); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); +#endif + + ThrowIfTasksNotSupported(client, nameof(UpdateTaskAsync)); + + // Manually construct the JSON params because InputResponses is backed by an internal + // property in Core that the extension's source-gen context cannot access for serialization. + JsonObject paramsObj = new() + { + ["taskId"] = requestParams.TaskId, + }; + + if (requestParams.InputResponses is { Count: > 0 } inputResponses) + { + paramsObj["inputResponses"] = JsonSerializer.SerializeToNode( + inputResponses, + McpJsonUtilities.DefaultOptions.GetTypeInfo>()); + } + + JsonRpcRequest jsonRpcRequest = new() + { + Method = TasksProtocol.MethodTasksUpdate, + Params = paramsObj, + }; + + JsonRpcResponse response = await client.SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false); + return response.Result?.Deserialize(McpTasksJsonContext.Default.UpdateTaskResult) + ?? new UpdateTaskResult(); + } + + /// + /// Requests task cancellation by ID. + /// + public static ValueTask CancelTaskAsync( + this McpClient client, + string taskId, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(taskId); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (taskId is null) throw new ArgumentNullException(nameof(taskId)); +#endif + + return client.CancelTaskAsync(new CancelTaskRequestParams { TaskId = taskId }, cancellationToken); + } + + /// + /// Requests task cancellation using explicit request parameters. + /// + public static ValueTask CancelTaskAsync( + this McpClient client, + CancelTaskRequestParams requestParams, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestParams); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); +#endif + + ThrowIfTasksNotSupported(client, nameof(CancelTaskAsync)); + return client.SendRequestAsync( + TasksProtocol.MethodTasksCancel, + requestParams, + McpTasksJsonContext.Default.Options, + cancellationToken: cancellationToken); + } + + private static async ValueTask PollTaskToCompletionAsync( + McpClient client, + CreateTaskResult taskCreated, + int maxConsecutiveStuckPolls, + CancellationToken cancellationToken) + { + string taskId = taskCreated.TaskId; + long pollIntervalMs = taskCreated.PollIntervalMs ?? 1000; + HashSet? resolvedRequestKeys = null; + bool isFirstPoll = true; + int consecutiveStuckPolls = 0; + + while (true) + { + if (!isFirstPoll) + { + await Task.Delay(TimeSpan.FromMilliseconds(pollIntervalMs), cancellationToken).ConfigureAwait(false); + } + + isFirstPoll = false; + + var taskResult = await GetTaskAsync(client, taskId, cancellationToken).ConfigureAwait(false); + if (taskResult.PollIntervalMs is { } newInterval) + { + pollIntervalMs = newInterval; + } + + switch (taskResult) + { + case CompletedTaskResult completed: + return JsonSerializer.Deserialize(completed.Result, McpJsonUtilities.DefaultOptions.GetTypeInfo()) + ?? throw new JsonException("Failed to deserialize CallToolResult from completed task."); + + case FailedTaskResult failed: + throw new McpException($"Task '{taskId}' failed: {failed.Error}"); + + case CancelledTaskResult: + throw new OperationCanceledException($"Task '{taskId}' was cancelled by the server."); + + case InputRequiredTaskResult inputRequired: + var newRequests = new Dictionary(); + if (inputRequired.InputRequests is { } incomingRequests) + { + foreach (var kvp in incomingRequests) + { + if (resolvedRequestKeys is null || !resolvedRequestKeys.Contains(kvp.Key)) + { + newRequests[kvp.Key] = kvp.Value; + } + } + } + + if (newRequests.Count > 0) + { + consecutiveStuckPolls = 0; + + IDictionary inputResponses; + try + { + inputResponses = await client.ResolveInputRequestsAsync(newRequests, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + try + { + await CancelTaskAsync(client, taskId, CancellationToken.None).ConfigureAwait(false); + } + catch + { + } + + throw; + } + + await UpdateTaskAsync(client, new UpdateTaskRequestParams + { + TaskId = taskId, + InputResponses = inputResponses, + }, cancellationToken).ConfigureAwait(false); + + resolvedRequestKeys ??= new HashSet(StringComparer.Ordinal); + foreach (var key in inputResponses.Keys) + { + resolvedRequestKeys.Add(key); + } + } + else if (++consecutiveStuckPolls >= maxConsecutiveStuckPolls) + { + try + { + await CancelTaskAsync(client, taskId, CancellationToken.None).ConfigureAwait(false); + } + catch + { + } + + throw new McpException( + $"Task '{taskId}' has remained in '{McpTaskStatus.InputRequired}' for {maxConsecutiveStuckPolls} consecutive polls " + + "without publishing new input requests after all previously requested inputs were resolved."); + } + + break; + + case WorkingTaskResult: + consecutiveStuckPolls = 0; + break; + + default: + throw new McpException($"Unexpected task result type '{taskResult.GetType().Name}' for task '{taskId}'."); + } + } + } + + private static JsonObject GetMetaWithTaskCapability(JsonObject? existingMeta) + { + JsonObject meta = existingMeta is not null + ? (JsonObject)existingMeta.DeepClone() + : []; + + if (meta[MetaKeys.ClientCapabilities] is not JsonObject capsRoot) + { + capsRoot = []; + meta[MetaKeys.ClientCapabilities] = capsRoot; + } + + if (capsRoot["extensions"] is not JsonObject extensionsRoot) + { + extensionsRoot = []; + capsRoot["extensions"] = extensionsRoot; + } + + if (!extensionsRoot.ContainsKey(TasksProtocol.ExtensionId)) + { + extensionsRoot[TasksProtocol.ExtensionId] = new JsonObject(); + } + + return meta; + } + + private static bool IsJuly2026OrLaterProtocol(McpClient client) => + McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(client.NegotiatedProtocolVersion); + + private static void ThrowIfTasksNotSupported(McpClient client, string operationName) + { + if (!IsJuly2026OrLaterProtocol(client)) + { + throw new InvalidOperationException( + $"'{operationName}' requires a newer protocol revision that supports tasks " + + $"(the '{McpHttpHeaders.July2026ProtocolVersion}' revision or later). " + + $"The negotiated protocol version is '{client.NegotiatedProtocolVersion ?? "(none)"}'."); + } + } +} diff --git a/src/ModelContextProtocol.Extensions.Tasks/McpTasksJsonContext.cs b/src/ModelContextProtocol.Extensions.Tasks/McpTasksJsonContext.cs new file mode 100644 index 000000000..13c92ee14 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/McpTasksJsonContext.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Serialization; +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Provides source-generated JSON serialization metadata for MCP Tasks extension types. +/// +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(CreateTaskResult))] +[JsonSerializable(typeof(GetTaskRequestParams))] +[JsonSerializable(typeof(GetTaskResult))] +[JsonSerializable(typeof(WorkingTaskResult))] +[JsonSerializable(typeof(CompletedTaskResult))] +[JsonSerializable(typeof(FailedTaskResult))] +[JsonSerializable(typeof(CancelledTaskResult))] +[JsonSerializable(typeof(InputRequiredTaskResult))] +[JsonSerializable(typeof(UpdateTaskRequestParams))] +[JsonSerializable(typeof(UpdateTaskResult))] +[JsonSerializable(typeof(CancelTaskRequestParams))] +[JsonSerializable(typeof(CancelTaskResult))] +[JsonSerializable(typeof(TaskStatusNotificationParams))] +[JsonSerializable(typeof(WorkingTaskNotificationParams))] +[JsonSerializable(typeof(CompletedTaskNotificationParams))] +[JsonSerializable(typeof(FailedTaskNotificationParams))] +[JsonSerializable(typeof(CancelledTaskNotificationParams))] +[JsonSerializable(typeof(InputRequiredTaskNotificationParams))] +public sealed partial class McpTasksJsonContext : JsonSerializerContext +{ +} diff --git a/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj b/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj new file mode 100644 index 000000000..e2b52f106 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/ModelContextProtocol.Extensions.Tasks.csproj @@ -0,0 +1,51 @@ + + + + net10.0;net9.0;net8.0;netstandard2.0 + true + true + ModelContextProtocol.Extensions.Tasks + MCP Tasks extension for the .NET Model Context Protocol (MCP) SDK + README.md + + $(NoWarn);MCPEXP001;MCPEXP002 + + false + + + + true + + + + + $(NoWarn);CS0436 + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ModelContextProtocol.Core/Protocol/CancelTaskRequestParams.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskRequestParams.cs similarity index 92% rename from src/ModelContextProtocol.Core/Protocol/CancelTaskRequestParams.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskRequestParams.cs index ee458d064..f4d6ff69f 100644 --- a/src/ModelContextProtocol.Core/Protocol/CancelTaskRequestParams.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskRequestParams.cs @@ -1,6 +1,7 @@ +using ModelContextProtocol.Protocol; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the parameters for a tasks/cancel request to signal intent to cancel an in-progress task. diff --git a/src/ModelContextProtocol.Core/Protocol/CancelTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskResult.cs similarity index 90% rename from src/ModelContextProtocol.Core/Protocol/CancelTaskResult.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskResult.cs index 4d066862b..c9d92a06e 100644 --- a/src/ModelContextProtocol.Core/Protocol/CancelTaskResult.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CancelTaskResult.cs @@ -1,6 +1,7 @@ +using ModelContextProtocol.Protocol; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the result of a tasks/cancel request. This is an empty acknowledgement. diff --git a/src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CreateTaskResult.cs similarity index 96% rename from src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/CreateTaskResult.cs index 6d9bac23c..9e262e5c0 100644 --- a/src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CreateTaskResult.cs @@ -1,8 +1,9 @@ +using ModelContextProtocol.Protocol; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the result returned by a server when it creates a task in lieu of a standard result. diff --git a/src/ModelContextProtocol.Core/Protocol/GetTaskRequestParams.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskRequestParams.cs similarity index 90% rename from src/ModelContextProtocol.Core/Protocol/GetTaskRequestParams.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskRequestParams.cs index 52b82d902..a865bf690 100644 --- a/src/ModelContextProtocol.Core/Protocol/GetTaskRequestParams.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskRequestParams.cs @@ -1,6 +1,7 @@ +using ModelContextProtocol.Protocol; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the parameters for a tasks/get request to poll for task completion. diff --git a/src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskResult.cs similarity index 98% rename from src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskResult.cs index 3fea8cc6b..4d083d8a1 100644 --- a/src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/GetTaskResult.cs @@ -1,9 +1,9 @@ -using System.Diagnostics.CodeAnalysis; +using ModelContextProtocol.Protocol; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the result of a tasks/get request, containing the full task state. @@ -169,7 +169,7 @@ internal sealed class Converter : JsonConverter } string requestKey = reader.GetString()!; reader.Read(); - var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.InputRequest) + var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.DefaultOptions.GetTypeInfo()) ?? throw new JsonException($"Failed to deserialize InputRequest for key '{requestKey}'."); inputRequests[requestKey] = inputRequest; } @@ -315,7 +315,7 @@ public override void Write(Utf8JsonWriter writer, GetTaskResult value, JsonSeria foreach (var kvp in reqs) { writer.WritePropertyName(kvp.Key); - JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.JsonContext.Default.InputRequest); + JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.DefaultOptions.GetTypeInfo()); } } writer.WriteEndObject(); diff --git a/src/ModelContextProtocol.Core/Protocol/McpTaskStatus.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/McpTaskStatus.cs similarity index 94% rename from src/ModelContextProtocol.Core/Protocol/McpTaskStatus.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/McpTaskStatus.cs index 3b705a947..90ee12d27 100644 --- a/src/ModelContextProtocol.Core/Protocol/McpTaskStatus.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/McpTaskStatus.cs @@ -1,6 +1,7 @@ +using ModelContextProtocol.Protocol; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the status of an MCP task. diff --git a/src/ModelContextProtocol.Core/Protocol/ResultOrCreatedTask.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/ResultOrCreatedTask.cs similarity index 91% rename from src/ModelContextProtocol.Core/Protocol/ResultOrCreatedTask.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/ResultOrCreatedTask.cs index 87b857470..1e9100c67 100644 --- a/src/ModelContextProtocol.Core/Protocol/ResultOrCreatedTask.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/ResultOrCreatedTask.cs @@ -1,4 +1,5 @@ -namespace ModelContextProtocol.Protocol; +using ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the result of a request that supports task-augmented execution, which may be either @@ -31,7 +32,10 @@ public class ResultOrCreatedTask where TResult : Result /// The standard result returned by the server. public ResultOrCreatedTask(TResult result) { - Throw.IfNull(result); + if (result is null) + { + throw new ArgumentNullException(nameof(result)); + } _result = result; } @@ -41,7 +45,10 @@ public ResultOrCreatedTask(TResult result) /// The task creation result returned by the server. public ResultOrCreatedTask(CreateTaskResult taskCreated) { - Throw.IfNull(taskCreated); + if (taskCreated is null) + { + throw new ArgumentNullException(nameof(taskCreated)); + } _taskCreated = taskCreated; } diff --git a/src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/TaskStatusNotificationParams.cs similarity index 98% rename from src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/TaskStatusNotificationParams.cs index 4e859a22c..020896a1f 100644 --- a/src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/TaskStatusNotificationParams.cs @@ -1,9 +1,9 @@ -using System.Diagnostics.CodeAnalysis; +using ModelContextProtocol.Protocol; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the parameters for a notifications/tasks notification sent by the server @@ -171,7 +171,7 @@ internal sealed class Converter : JsonConverter } string requestKey = reader.GetString()!; reader.Read(); - var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.InputRequest) + var inputRequest = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.DefaultOptions.GetTypeInfo()) ?? throw new JsonException($"Failed to deserialize InputRequest for key '{requestKey}'."); inputRequests[requestKey] = inputRequest; } @@ -311,7 +311,7 @@ public override void Write(Utf8JsonWriter writer, TaskStatusNotificationParams v foreach (var kvp in reqs) { writer.WritePropertyName(kvp.Key); - JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.JsonContext.Default.InputRequest); + JsonSerializer.Serialize(writer, kvp.Value, McpJsonUtilities.DefaultOptions.GetTypeInfo()); } } writer.WriteEndObject(); @@ -390,4 +390,3 @@ public sealed class InputRequiredTaskNotificationParams : TaskStatusNotification [JsonPropertyName("inputRequests")] public IDictionary? InputRequests { get; set; } } - diff --git a/src/ModelContextProtocol.Core/Protocol/UpdateTaskRequestParams.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskRequestParams.cs similarity index 93% rename from src/ModelContextProtocol.Core/Protocol/UpdateTaskRequestParams.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskRequestParams.cs index 07b45de15..aeeb2c79a 100644 --- a/src/ModelContextProtocol.Core/Protocol/UpdateTaskRequestParams.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskRequestParams.cs @@ -1,6 +1,7 @@ +using ModelContextProtocol.Protocol; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the parameters for a tasks/update request to provide input responses diff --git a/src/ModelContextProtocol.Core/Protocol/UpdateTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskResult.cs similarity index 88% rename from src/ModelContextProtocol.Core/Protocol/UpdateTaskResult.cs rename to src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskResult.cs index 531039c23..b9f59f395 100644 --- a/src/ModelContextProtocol.Core/Protocol/UpdateTaskResult.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/UpdateTaskResult.cs @@ -1,6 +1,7 @@ +using ModelContextProtocol.Protocol; using System.Text.Json.Serialization; -namespace ModelContextProtocol.Protocol; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the result of a tasks/update request. This is an empty acknowledgement. diff --git a/src/ModelContextProtocol.Core/Server/IMcpTaskStore.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/IMcpTaskStore.cs similarity index 99% rename from src/ModelContextProtocol.Core/Server/IMcpTaskStore.cs rename to src/ModelContextProtocol.Extensions.Tasks/Server/IMcpTaskStore.cs index 337cb1946..6851f21ac 100644 --- a/src/ModelContextProtocol.Core/Server/IMcpTaskStore.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/IMcpTaskStore.cs @@ -1,7 +1,7 @@ using ModelContextProtocol.Protocol; using System.Text.Json; -namespace ModelContextProtocol.Server; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Provides an interface for storing and managing the lifecycle of MCP tasks. diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/InMemoryMcpTaskStore.cs similarity index 99% rename from src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs rename to src/ModelContextProtocol.Extensions.Tasks/Server/InMemoryMcpTaskStore.cs index 762598226..1b8b61246 100644 --- a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/InMemoryMcpTaskStore.cs @@ -3,7 +3,7 @@ using System.Collections.Immutable; using System.Text.Json; -namespace ModelContextProtocol.Server; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Provides an in-memory implementation of for development and testing scenarios. diff --git a/src/ModelContextProtocol.Core/Server/InputResponseReceivedEventArgs.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/InputResponseReceivedEventArgs.cs similarity index 92% rename from src/ModelContextProtocol.Core/Server/InputResponseReceivedEventArgs.cs rename to src/ModelContextProtocol.Extensions.Tasks/Server/InputResponseReceivedEventArgs.cs index 14447bd6f..c1cedf6d1 100644 --- a/src/ModelContextProtocol.Core/Server/InputResponseReceivedEventArgs.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/InputResponseReceivedEventArgs.cs @@ -1,6 +1,6 @@ using ModelContextProtocol.Protocol; -namespace ModelContextProtocol.Server; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Provides data for the event. diff --git a/src/ModelContextProtocol.Core/Server/McpTaskInfo.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskInfo.cs similarity index 94% rename from src/ModelContextProtocol.Core/Server/McpTaskInfo.cs rename to src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskInfo.cs index d275fb3a6..a9a86ed54 100644 --- a/src/ModelContextProtocol.Core/Server/McpTaskInfo.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTaskInfo.cs @@ -1,7 +1,7 @@ using ModelContextProtocol.Protocol; using System.Text.Json; -namespace ModelContextProtocol.Server; +namespace ModelContextProtocol.Extensions.Tasks; /// /// Represents the state of a task in an . diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs new file mode 100644 index 000000000..ec01e86bd --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs @@ -0,0 +1,301 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Collections.Concurrent; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Extension methods for to enable MCP Tasks support. +/// +public static class McpTasksBuilderExtensions +{ + /// + /// Enables MCP Tasks support backed by the specified task store. + /// + /// The server builder. + /// The task store. + /// The builder provided in . + public static IMcpServerBuilder WithTasks(this IMcpServerBuilder builder, IMcpTaskStore store) + { +#if NET + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(store); +#else + if (builder is null) throw new ArgumentNullException(nameof(builder)); + if (store is null) throw new ArgumentNullException(nameof(store)); +#endif + + builder.Services.AddSingleton>(new McpTasksPostConfigureOptions(store)); + return builder; + } + + private sealed class McpTasksPostConfigureOptions(IMcpTaskStore store) : IPostConfigureOptions + { + private readonly IMcpTaskStore _store = store; + private readonly ConcurrentDictionary _cancellationSources = new(StringComparer.Ordinal); + + public void PostConfigure(string? name, McpServerOptions options) + { +#if NET + ArgumentNullException.ThrowIfNull(options); +#else + if (options is null) throw new ArgumentNullException(nameof(options)); +#endif + + options.Capabilities ??= new ServerCapabilities(); + options.Capabilities.Extensions ??= new Dictionary(); + if (!options.Capabilities.Extensions.ContainsKey(TasksProtocol.ExtensionId)) + { + options.Capabilities.Extensions[TasksProtocol.ExtensionId] = new JsonObject(); + } + + options.RequestHandlers ??= new List(); + options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksGet, Handler = HandleGetTask }); + options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksUpdate, Handler = HandleUpdateTask }); + options.RequestHandlers.Add(new McpServerRequestHandler { Method = TasksProtocol.MethodTasksCancel, Handler = HandleCancelTask }); + + // Use a filter rather than a handler so it wraps around Core's tool dispatch. + // This ensures it intercepts tool calls BEFORE the tool is invoked, allowing + // it to spawn background execution and return the task alternate immediately. + options.Filters.Request.CallToolWithAlternateFilters.Add(next => async (request, cancellationToken) => + { + if (IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest) && HasTaskExtensionOptIn(request.Params?.Meta)) + { + var taskInfo = await _store.CreateTaskAsync(cancellationToken).ConfigureAwait(false); + var taskId = taskInfo.TaskId; + var cts = new CancellationTokenSource(); + _cancellationSources[taskId] = cts; + var taskCancellationToken = cts.Token; + + _ = Task.Run(async () => + { + using (McpTasksServerExtensions.CreateMcpTaskScope(request.Server, taskId, _store)) + { + try + { + var augmented = await next(request, taskCancellationToken).ConfigureAwait(false); + + if (augmented.IsAlternate) + { + var error = new JsonRpcErrorDetail + { + Code = (int)McpErrorCode.InternalError, + Message = $"{nameof(IMcpTaskStore)} is configured and the {nameof(McpServerHandlers.CallToolWithAlternateHandler)} returned IsAlternate = true. Use only one mechanism.", + }; + var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); + return; + } + + var resultJson = JsonSerializer.SerializeToElement(augmented.Result!, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + await _store.SetCompletedAsync(taskId, resultJson).ConfigureAwait(false); + } + catch (OperationCanceledException) when (taskCancellationToken.IsCancellationRequested) + { + await _store.SetCancelledAsync(taskId, CancellationToken.None).ConfigureAwait(false); + } + catch (InputRequiredException) + { + var error = new JsonRpcErrorDetail + { + Code = (int)McpErrorCode.InvalidRequest, + Message = "MRTR and tasks cannot be composed via [McpServerTool] yet.", + }; + var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); + } + catch (McpProtocolException mcpEx) + { + // SEP-2663 §186: protocol exceptions store as failed with JSON-RPC error shape. + var error = new JsonRpcErrorDetail { Code = (int)mcpEx.ErrorCode, Message = mcpEx.Message }; + var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); + } + catch (Exception ex) + { + // Non-protocol exceptions are wrapped as CallToolResult { IsError = true }, + // matching Core's BuildInitialAlternateToolFilter behavior. + var errorResult = new CallToolResult + { + IsError = true, + Content = [new TextContentBlock + { + Text = ex is McpException + ? $"An error occurred invoking '{request.Params?.Name}': {ex.Message}" + : $"An error occurred invoking '{request.Params?.Name}'.", + }], + }; + var resultJson = JsonSerializer.SerializeToElement(errorResult, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + await _store.SetCompletedAsync(taskId, resultJson).ConfigureAwait(false); + } + finally + { + if (_cancellationSources.TryRemove(taskId, out var registeredCts)) + { + registeredCts.Dispose(); + } + } + } + }, CancellationToken.None); + + return new ResultOrAlternate( + ToCreateTaskResult(taskInfo), + McpTasksJsonContext.Default.CreateTaskResult); + } + + return await next(request, cancellationToken).ConfigureAwait(false); + }); + } + + private async ValueTask HandleGetTask(JsonRpcRequest request, CancellationToken cancellationToken) + { + GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksGet); + + var requestParams = request.Params?.Deserialize(McpTasksJsonContext.Default.GetTaskRequestParams) + ?? throw new McpProtocolException("Missing params for tasks/get", McpErrorCode.InvalidParams); + + var info = await _store.GetTaskAsync(requestParams.TaskId, cancellationToken).ConfigureAwait(false); + if (info is null) + { + throw new McpProtocolException($"Unknown task: '{requestParams.TaskId}'", McpErrorCode.InvalidParams); + } + + return JsonSerializer.SerializeToNode(ToGetTaskResult(info), McpTasksJsonContext.Default.GetTaskResult); + } + + private async ValueTask HandleUpdateTask(JsonRpcRequest request, CancellationToken cancellationToken) + { + GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksUpdate); + + var taskId = request.Params?["taskId"]?.GetValue() + ?? throw new McpProtocolException("Missing params.taskId for tasks/update", McpErrorCode.InvalidParams); + + // Deserialize inputResponses using Core's options which can access the internal + // InputResponsesCore backing property on RequestParams. The extension's source-gen + // context cannot see that internal member. + var inputResponses = request.Params?["inputResponses"]?.Deserialize( + McpJsonUtilities.DefaultOptions.GetTypeInfo>()) + ?? new Dictionary(); + + await _store.ResolveInputRequestsAsync(taskId, inputResponses, cancellationToken).ConfigureAwait(false); + + return JsonSerializer.SerializeToNode(new UpdateTaskResult(), McpTasksJsonContext.Default.UpdateTaskResult); + } + + private async ValueTask HandleCancelTask(JsonRpcRequest request, CancellationToken cancellationToken) + { + GateToJuly2026OrLaterProtocol(request, TasksProtocol.MethodTasksCancel); + + var requestParams = request.Params?.Deserialize(McpTasksJsonContext.Default.CancelTaskRequestParams) + ?? throw new McpProtocolException("Missing params for tasks/cancel", McpErrorCode.InvalidParams); + + await _store.SetCancelledAsync(requestParams.TaskId, cancellationToken).ConfigureAwait(false); + + if (_cancellationSources.TryRemove(requestParams.TaskId, out var cts)) + { + cts.Cancel(); + cts.Dispose(); + } + + return JsonSerializer.SerializeToNode(new CancelTaskResult(), McpTasksJsonContext.Default.CancelTaskResult); + } + + private static void GateToJuly2026OrLaterProtocol(JsonRpcRequest request, string method) + { + if (!IsJuly2026OrLaterProtocolRequest(request)) + { + throw new McpProtocolException( + $"The method '{method}' requires a newer protocol revision that supports tasks " + + $"(the '{McpHttpHeaders.July2026ProtocolVersion}' revision or later); " + + $"the negotiated protocol version is '{request?.Context?.ProtocolVersion ?? "(none)"}'.", + McpErrorCode.MethodNotFound); + } + } + + private static bool HasTaskExtensionOptIn(JsonObject? meta) => + meta is not null && + meta[MetaKeys.ClientCapabilities] is JsonObject caps && + caps["extensions"] is JsonObject exts && + exts.ContainsKey(TasksProtocol.ExtensionId); + + private static bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request) => + McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(request?.Context?.ProtocolVersion); + + private static CreateTaskResult ToCreateTaskResult(McpTaskInfo info) => new() + { + TaskId = info.TaskId, + Status = info.Status, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TimeToLive = info.TimeToLive, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + ResultType = "task", + }; + + private static GetTaskResult ToGetTaskResult(McpTaskInfo info) => info.Status switch + { + McpTaskStatus.Working => new WorkingTaskResult + { + TaskId = info.TaskId, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TimeToLive = info.TimeToLive, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + ResultType = "complete", + }, + McpTaskStatus.Completed => new CompletedTaskResult + { + TaskId = info.TaskId, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TimeToLive = info.TimeToLive, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + Result = info.Result ?? throw new InvalidOperationException($"Task '{info.TaskId}' is completed but has no result."), + ResultType = "complete", + }, + McpTaskStatus.Failed => new FailedTaskResult + { + TaskId = info.TaskId, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TimeToLive = info.TimeToLive, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + Error = info.Error ?? throw new InvalidOperationException($"Task '{info.TaskId}' is failed but has no error."), + ResultType = "complete", + }, + McpTaskStatus.Cancelled => new CancelledTaskResult + { + TaskId = info.TaskId, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TimeToLive = info.TimeToLive, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + ResultType = "complete", + }, + McpTaskStatus.InputRequired => new InputRequiredTaskResult + { + TaskId = info.TaskId, + CreatedAt = info.CreatedAt, + LastUpdatedAt = info.LastUpdatedAt, + TimeToLive = info.TimeToLive, + PollIntervalMs = info.PollIntervalMs, + StatusMessage = info.StatusMessage, + InputRequests = info.InputRequests is IDictionary dict + ? dict + : info.InputRequests?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) ?? new Dictionary(), + ResultType = "complete", + }, + _ => throw new InvalidOperationException($"Unknown task status: {info.Status}"), + }; + } +} diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksServerExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksServerExtensions.cs new file mode 100644 index 000000000..31445fd1f --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksServerExtensions.cs @@ -0,0 +1,109 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Extension methods for task-aware server operations. +/// +public static class McpTasksServerExtensions +{ + /// + /// Sends a task status notification to the connected client. + /// + /// The server sending the notification. + /// The notification payload. + /// The cancellation token. + /// A task representing the send operation. + public static Task SendTaskStatusNotificationAsync( + this McpServer server, + TaskStatusNotificationParams notificationParams, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(server); + ArgumentNullException.ThrowIfNull(notificationParams); +#else + if (server is null) throw new ArgumentNullException(nameof(server)); + if (notificationParams is null) throw new ArgumentNullException(nameof(notificationParams)); +#endif + + return server.SendNotificationAsync( + TasksProtocol.NotificationTaskStatus, + notificationParams, + McpTasksJsonContext.Default.Options, + cancellationToken); + } + + /// + /// Creates a scope that routes server-initiated requests through the specified task store. + /// + /// The server whose outgoing requests should be redirected. + /// The related task identifier. + /// The task store used to surface input requests. + /// An that restores the previous outgoing-request behavior. + public static IDisposable CreateMcpTaskScope(this McpServer server, string taskId, IMcpTaskStore store) + { +#if NET + ArgumentNullException.ThrowIfNull(server); + ArgumentNullException.ThrowIfNull(taskId); + ArgumentNullException.ThrowIfNull(store); +#else + if (server is null) throw new ArgumentNullException(nameof(server)); + if (taskId is null) throw new ArgumentNullException(nameof(taskId)); + if (store is null) throw new ArgumentNullException(nameof(store)); +#endif + + return server.InterceptOutgoingRequests(async (method, paramsNode, cancellationToken) => + { + var requestId = Guid.NewGuid().ToString("N"); + + var inputRequest = new InputRequest + { + Method = method, + Params = paramsNode is null + ? default + : JsonSerializer.SerializeToElement(paramsNode, McpJsonUtilities.DefaultOptions.GetTypeInfo()), + }; + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void handler(InputResponseReceivedEventArgs args) + { + if (args.TaskId == taskId && args.RequestId == requestId) + { + tcs.TrySetResult(args.Response); + } + } + + store.InputResponseReceived += handler; + try + { + await store.SetInputRequestsAsync( + taskId, + new Dictionary { [requestId] = inputRequest }, + cancellationToken).ConfigureAwait(false); + +#if NET + var response = await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); +#else + using (cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken))) + { + var response = await tcs.Task.ConfigureAwait(false); + return JsonNode.Parse(response.RawValue.GetRawText()); + } +#endif + +#if NET + return JsonNode.Parse(response.RawValue.GetRawText()); +#endif + } + finally + { + store.InputResponseReceived -= handler; + } + }); + } +} diff --git a/src/ModelContextProtocol.Extensions.Tasks/TasksProtocol.cs b/src/ModelContextProtocol.Extensions.Tasks/TasksProtocol.cs new file mode 100644 index 000000000..44172666a --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Tasks/TasksProtocol.cs @@ -0,0 +1,37 @@ +namespace ModelContextProtocol.Extensions.Tasks; + +/// +/// Provides constants for the MCP Tasks extension (SEP-2663). +/// +public static class TasksProtocol +{ + /// + /// The extension identifier for the MCP Tasks extension. + /// + public const string ExtensionId = "io.modelcontextprotocol/tasks"; + + /// + /// The name of the request method sent from the client to poll for task completion. + /// + public const string MethodTasksGet = "tasks/get"; + + /// + /// The name of the request method sent from the client to provide input responses to a task. + /// + public const string MethodTasksUpdate = "tasks/update"; + + /// + /// The name of the request method sent from the client to signal intent to cancel a task. + /// + public const string MethodTasksCancel = "tasks/cancel"; + + /// + /// The name of the notification sent by the server when a task's status changes. + /// + public const string NotificationTaskStatus = "notifications/tasks/status"; + + /// + /// The metadata key used to associate requests, responses, and notifications with a task. + /// + public const string MetaRelatedTask = "io.modelcontextprotocol/related-task"; +} diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs index 879173819..af1334fab 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientTaskMethodsTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -11,7 +12,7 @@ namespace ModelContextProtocol.Tests.Client; /// /// Integration tests for the client-side task API methods: GetTaskAsync, CancelTaskAsync, -/// UpdateTaskAsync, CallToolRawAsync, and the automatic polling in CallToolAsync. +/// UpdateTaskAsync, CallToolAsTaskAsync, and the automatic polling in CallToolWithPollingAsync. /// public class McpClientTaskMethodsTests : ClientServerTestBase { @@ -25,15 +26,12 @@ public McpClientTaskMethodsTests(ITestOutputHelper outputHelper) protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - mcpServerBuilder.Services.Configure(options => - { - options.TaskStore = new InMemoryMcpTaskStore + mcpServerBuilder + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, - }; - }); - - mcpServerBuilder.WithTools([McpServerTool.Create( + }) + .WithTools([McpServerTool.Create( async (string input, CancellationToken ct) => { await Task.Delay(50, ct); @@ -60,7 +58,7 @@ public async Task GetTaskAsync_ReturnsTaskStatus() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", @@ -97,12 +95,12 @@ await Assert.ThrowsAsync(async () => } [Fact] - public async Task CallToolRawAsync_WithTaskStore_ReturnsCreatedTask() + public async Task CallToolAsTaskAsync_WithTaskStore_ReturnsCreatedTask() { await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", @@ -122,12 +120,12 @@ public async Task CallToolAsync_PollsUntilCompletion_ReturnsResult() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolAsync( + var result = await client.CallToolWithPollingAsync( new CallToolRequestParams { Name = "test-tool", Arguments = CreateArguments("input", "hello"), - }, ct); + }, cancellationToken: ct); Assert.NotNull(result); Assert.NotEmpty(result.Content); @@ -141,7 +139,7 @@ public async Task CancelTaskAsync_ForWorkingTask_Succeeds() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", @@ -172,7 +170,7 @@ public async Task CancelTaskAsync_NullTaskId_Throws() await using var client = await CreateMcpClientForServer(); await Assert.ThrowsAsync(async () => - await client.CancelTaskAsync((string)null!, TestContext.Current.CancellationToken)); + await client.CancelTaskAsync((string)null!, cancellationToken: TestContext.Current.CancellationToken)); } [Fact] @@ -194,7 +192,7 @@ public async Task GetTaskAsync_AfterCompletion_ReturnsCompletedResult() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", @@ -235,7 +233,7 @@ public async Task MultipleTasks_CreatedConcurrently_HaveUniqueIds() for (int i = 0; i < 5; i++) { - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index 4b782cd64..c9fdff85f 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -83,6 +83,7 @@ + diff --git a/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs index 0a0f510a4..da4afb62f 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using ModelContextProtocol.Protocol; using System.Text.Json; using System.Text.Json.Nodes; @@ -27,8 +28,8 @@ public static void CreateTaskResult_SerializationRoundTrip_PreservesAllPropertie Meta = new JsonObject { ["key"] = "value" } }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); Assert.NotNull(deserialized); Assert.Equal("task-123", deserialized.TaskId); @@ -57,7 +58,7 @@ public static void CreateTaskResult_UsesCorrectWireFieldNames() ResultType = "task", }; - string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(result, McpTasksJsonContext.Default.Options); // Must use camelCase wire names Assert.Contains("\"ttlMs\":", json); @@ -82,7 +83,7 @@ public static void CreateTaskResult_ResultType_SerializesAsTask() ResultType = "task", }; - string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(result, McpTasksJsonContext.Default.Options); var node = JsonNode.Parse(json)!; Assert.Equal("task", (string)node["resultType"]!); @@ -104,8 +105,8 @@ public static void GetTaskResult_Working_RoundTrip() PollIntervalMs = 2000, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var working = Assert.IsType(deserialized); Assert.Equal("w1", working.TaskId); @@ -126,8 +127,8 @@ public static void GetTaskResult_Completed_RoundTrip_IncludesResult() Result = resultPayload, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var completed = Assert.IsType(deserialized); Assert.Equal("c1", completed.TaskId); @@ -147,8 +148,8 @@ public static void GetTaskResult_Failed_RoundTrip_IncludesError() Error = errorPayload, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var failed = Assert.IsType(deserialized); Assert.Equal("f1", failed.TaskId); @@ -167,8 +168,8 @@ public static void GetTaskResult_Cancelled_RoundTrip() StatusMessage = "User cancelled", }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var cancelled = Assert.IsType(deserialized); Assert.Equal("x1", cancelled.TaskId); @@ -195,8 +196,8 @@ public static void GetTaskResult_InputRequired_RoundTrip_IncludesInputRequests() InputRequests = inputRequests, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var inputRequired = Assert.IsType(deserialized); Assert.Equal("i1", inputRequired.TaskId); @@ -228,7 +229,7 @@ public static void GetTaskResult_Converter_DispatchesToCorrectSubtypeByStatus() _ => $$$"""{"taskId":"t","status":"{{{status}}}","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}""", }; - var result = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + var result = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); Assert.NotNull(result); Assert.IsType(expectedType, result); } @@ -238,42 +239,42 @@ public static void GetTaskResult_Converter_DispatchesToCorrectSubtypeByStatus() public static void GetTaskResult_MissingTaskId_ThrowsJsonException() { var json = """{"status":"working","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; - Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options)); } [Fact] public static void GetTaskResult_MissingStatus_ThrowsJsonException() { var json = """{"taskId":"t","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; - Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options)); } [Fact] public static void GetTaskResult_UnknownStatus_ThrowsJsonException() { var json = """{"taskId":"t","status":"exploded","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; - Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options)); } [Fact] public static void GetTaskResult_CompletedMissingResult_ThrowsJsonException() { var json = """{"taskId":"t","status":"completed","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; - Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options)); } [Fact] public static void GetTaskResult_FailedMissingError_ThrowsJsonException() { var json = """{"taskId":"t","status":"failed","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; - Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options)); } [Fact] public static void GetTaskResult_InputRequiredMissingInputRequests_ThrowsJsonException() { var json = """{"taskId":"t","status":"input_required","createdAt":"2025-01-01T00:00:00Z","lastUpdatedAt":"2025-01-01T00:00:00Z"}"""; - Assert.Throws(() => JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)); + Assert.Throws(() => JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options)); } [Theory] @@ -303,14 +304,14 @@ public static void GetTaskResult_WireResultType_IsComplete_WhenSet(Type subType) ["k"] = new InputRequest { Method = "test/method", - Params = JsonSerializer.SerializeToElement("ask", McpJsonUtilities.DefaultOptions), + Params = JsonSerializer.SerializeToElement("ask", McpTasksJsonContext.Default.Options), }, }, }, _ => throw new InvalidOperationException() }; - string json = JsonSerializer.Serialize(value, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(value, McpTasksJsonContext.Default.Options); var node = JsonNode.Parse(json)!; Assert.Equal("complete", (string?)node["resultType"]); @@ -328,10 +329,10 @@ public static void GetTaskResult_WireResultType_IsComplete_WhenSet(Type subType) [InlineData(McpTaskStatus.Failed, "failed")] public static void McpTaskStatus_SerializesAsSnakeCase(McpTaskStatus status, string expectedWireValue) { - string json = JsonSerializer.Serialize(status, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(status, McpTasksJsonContext.Default.Options); Assert.Equal($"\"{expectedWireValue}\"", json); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); Assert.Equal(status, deserialized); } @@ -350,8 +351,8 @@ public static void TaskStatusNotificationParams_Working_RoundTrip() StatusMessage = "Working on it", }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var working = Assert.IsType(deserialized); Assert.Equal("n1", working.TaskId); @@ -370,8 +371,8 @@ public static void TaskStatusNotificationParams_Completed_RoundTrip() Result = resultPayload, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var completed = Assert.IsType(deserialized); Assert.Equal("n2", completed.TaskId); @@ -390,8 +391,8 @@ public static void TaskStatusNotificationParams_Failed_RoundTrip() Error = errorPayload, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var failed = Assert.IsType(deserialized); Assert.Equal("n3", failed.TaskId); @@ -408,8 +409,8 @@ public static void TaskStatusNotificationParams_Cancelled_RoundTrip() LastUpdatedAt = DateTimeOffset.UtcNow, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); Assert.IsType(deserialized); } @@ -429,8 +430,8 @@ public static void TaskStatusNotificationParams_InputRequired_RoundTrip() InputRequests = inputRequests, }; - string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); - var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(original, McpTasksJsonContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, McpTasksJsonContext.Default.Options); var inputRequired = Assert.IsType(deserialized); Assert.NotNull(inputRequired.InputRequests); @@ -496,7 +497,7 @@ public static void UpdateTaskResult_WireResultType_IsComplete_WhenSet() { // SEP-2663: tasks/update responses use resultType="complete". var result = new UpdateTaskResult { ResultType = "complete" }; - string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(result, McpTasksJsonContext.Default.Options); var node = JsonNode.Parse(json)!; Assert.Equal("complete", (string?)node["resultType"]); @@ -507,7 +508,7 @@ public static void CancelTaskResult_WireResultType_IsComplete_WhenSet() { // SEP-2663: tasks/cancel responses use resultType="complete". var result = new CancelTaskResult { ResultType = "complete" }; - string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions); + string json = JsonSerializer.Serialize(result, McpTasksJsonContext.Default.Options); var node = JsonNode.Parse(json)!; Assert.Equal("complete", (string?)node["resultType"]); diff --git a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs index 83afbfe23..5015a5f30 100644 --- a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Text.Json; diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs index 9b73d0b0e..94bb4dcd0 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Microsoft.Extensions.DependencyInjection; @@ -5,6 +6,8 @@ using System.Text.Json; using System.Text.Json.Nodes; +#pragma warning disable MCPEXP001, MCPEXP002 + namespace ModelContextProtocol.Tests.Server; /// @@ -29,74 +32,97 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer mcpServerBuilder.Services.Configure(options => { options.Capabilities ??= new ServerCapabilities(); + options.Capabilities.Extensions ??= new Dictionary(); + options.Capabilities.Extensions[TasksProtocol.ExtensionId] = new JsonObject(); + options.RequestHandlers ??= new List(); - options.Handlers.CallToolWithAlternateHandler = async (context, cancellationToken) => + options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => { _capturedMeta = context.Params?.Meta; var store = context.Server.Services!.GetRequiredService(); var toolName = context.Params!.Name; - if (toolName == "immediate-tool") + ResultOrAlternate result = toolName switch { - return new CallToolResult() + "immediate-tool" => new(new CallToolResult { Content = [new TextContentBlock { Text = "immediate result" }], - }; - } + }), + "async-tool" => new( + new CreateTaskResult + { + TaskId = store.CreateTask(), + Status = McpTaskStatus.Working, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + PollIntervalMs = 50, + ResultType = "task", + }, + McpTasksJsonContext.Default.CreateTaskResult), + "input-required-tool" => new( + new CreateTaskResult + { + TaskId = store.CreateTask(McpTaskStatus.InputRequired), + Status = McpTaskStatus.InputRequired, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + PollIntervalMs = 50, + ResultType = "task", + }, + McpTasksJsonContext.Default.CreateTaskResult), + _ => throw new McpException($"Unknown tool: {toolName}"), + }; - if (toolName == "async-tool") - { - var taskId = store.CreateTask(); - return new CreateTaskResult - { - TaskId = taskId, - Status = McpTaskStatus.Working, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - PollIntervalMs = 50, - ResultType = "task", - }; - } + return new ValueTask>(result); + }; - if (toolName == "input-required-tool") + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = TasksProtocol.MethodTasksGet, + Handler = (request, cancellationToken) => { - var taskId = store.CreateTask(McpTaskStatus.InputRequired); - return new CreateTaskResult + var requestParams = JsonSerializer.Deserialize(request.Params, McpTasksJsonContext.Default.Options) + ?? throw new McpProtocolException("Missing params for tasks/get", McpErrorCode.InvalidParams); + + GetTaskResult result; + try { - TaskId = taskId, - Status = McpTaskStatus.InputRequired, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - PollIntervalMs = 50, - ResultType = "task", - }; - } + result = _taskStore.GetTask(requestParams.TaskId); + } + catch (McpException ex) + { + throw new McpProtocolException(ex.Message, McpErrorCode.InvalidParams); + } - throw new McpException($"Unknown tool: {toolName}"); - }; + return new ValueTask(JsonSerializer.SerializeToNode(result, McpTasksJsonContext.Default.Options)); + }, + }); - options.Handlers.GetTaskHandler = async (context, cancellationToken) => + options.RequestHandlers.Add(new McpServerRequestHandler { - var store = context.Server.Services!.GetRequiredService(); - var taskId = context.Params!.TaskId; - return store.GetTask(taskId); - }; + Method = TasksProtocol.MethodTasksUpdate, + Handler = (request, cancellationToken) => + { + var requestParams = JsonSerializer.Deserialize(request.Params, McpTasksJsonContext.Default.Options) + ?? throw new McpProtocolException("Missing params for tasks/update", McpErrorCode.InvalidParams); - options.Handlers.UpdateTaskHandler = async (context, cancellationToken) => - { - var store = context.Server.Services!.GetRequiredService(); - var taskId = context.Params!.TaskId; - store.ProvideInput(taskId, context.Params.InputResponses ?? new Dictionary()); - return new UpdateTaskResult(); - }; + _taskStore.ProvideInput(requestParams.TaskId, requestParams.InputResponses ?? new Dictionary()); + return new ValueTask(JsonSerializer.SerializeToNode(new UpdateTaskResult(), McpTasksJsonContext.Default.Options)); + }, + }); - options.Handlers.CancelTaskHandler = async (context, cancellationToken) => + options.RequestHandlers.Add(new McpServerRequestHandler { - var store = context.Server.Services!.GetRequiredService(); - var taskId = context.Params!.TaskId; - store.CancelTask(taskId); - return new CancelTaskResult(); - }; + Method = TasksProtocol.MethodTasksCancel, + Handler = (request, cancellationToken) => + { + var requestParams = JsonSerializer.Deserialize(request.Params, McpTasksJsonContext.Default.Options) + ?? throw new McpProtocolException("Missing params for tasks/cancel", McpErrorCode.InvalidParams); + + _taskStore.CancelTask(requestParams.TaskId); + return new ValueTask(JsonSerializer.SerializeToNode(new CancelTaskResult(), McpTasksJsonContext.Default.Options)); + }, + }); }); } @@ -105,9 +131,9 @@ public async Task CallToolAsync_ImmediateResult_ReturnsDirectly() { await using var client = await CreateMcpClientForServer(); - var result = await client.CallToolAsync( + var result = await client.CallToolWithPollingAsync( new CallToolRequestParams { Name = "immediate-tool" }, - TestContext.Current.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Single(result.Content); @@ -115,11 +141,11 @@ public async Task CallToolAsync_ImmediateResult_ReturnsDirectly() } [Fact] - public async Task CallToolRawAsync_ImmediateResult_ReturnsResultNotTask() + public async Task CallToolAsTaskAsync_ImmediateResult_ReturnsResultNotTask() { await using var client = await CreateMcpClientForServer(); - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "immediate-tool" }, TestContext.Current.CancellationToken); @@ -130,11 +156,11 @@ public async Task CallToolRawAsync_ImmediateResult_ReturnsResultNotTask() } [Fact] - public async Task CallToolRawAsync_AsyncTool_ReturnsTaskCreated() + public async Task CallToolAsTaskAsync_AsyncTool_ReturnsTaskCreated() { await using var client = await CreateMcpClientForServer(); - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "async-tool" }, TestContext.Current.CancellationToken); @@ -154,7 +180,7 @@ public async Task CallToolAsync_AsyncTool_PollsUntilCompleted() // Complete the task after a brief delay so polling finds it. _ = Task.Run(async () => { - await Task.Delay(100, ct); + await Task.Delay(100, cancellationToken: ct); // The store should have exactly one task by now var taskId = _taskStore.GetAllTaskIds().Single(); _taskStore.CompleteTask(taskId, new CallToolResult @@ -163,9 +189,8 @@ public async Task CallToolAsync_AsyncTool_PollsUntilCompleted() }); }, ct); - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "async-tool" }, - ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "async-tool" }, cancellationToken: ct); Assert.NotNull(result); Assert.Single(result.Content); @@ -192,9 +217,8 @@ public async Task CallToolAsync_AsyncTool_FailedTask_ThrowsMcpException() }; await Assert.ThrowsAsync(async () => - await client.CallToolAsync( - new CallToolRequestParams { Name = "async-tool" }, - ct)); + await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "async-tool" }, cancellationToken: ct)); Assert.True(await failedTask.Task); } @@ -219,9 +243,8 @@ public async Task CallToolAsync_AsyncTool_CancelledTask_ThrowsOperationCancelled }; await Assert.ThrowsAsync(async () => - await client.CallToolAsync( - new CallToolRequestParams { Name = "async-tool" }, - ct)); + await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "async-tool" }, cancellationToken: ct)); Assert.True(await cancelledTask.Task); } @@ -231,10 +254,10 @@ public async Task GetTaskAsync_ReturnsCurrentState() { await using var client = await CreateMcpClientForServer(); - // Create a task via CallToolRawAsync - var augmented = await client.CallToolRawAsync( + // Create a task via CallToolAsTaskAsync + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "async-tool" }, - TestContext.Current.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken); var taskId = augmented.TaskCreated!.TaskId; @@ -250,7 +273,7 @@ public async Task CancelTaskAsync_CancelsTask() { await using var client = await CreateMcpClientForServer(); - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "async-tool" }, TestContext.Current.CancellationToken); @@ -276,7 +299,7 @@ public async Task ConfigureTasks_AdvertisesExtensionInCapabilities() var extensions = client.ServerCapabilities.Extensions; #pragma warning restore MCP_EXTENSIONS Assert.NotNull(extensions); - Assert.True(extensions.ContainsKey(McpExtensions.Tasks)); + Assert.True(extensions.ContainsKey(TasksProtocol.ExtensionId)); } [Fact] @@ -284,7 +307,7 @@ public async Task CreateTaskResult_HasResultTypeTask() { await using var client = await CreateMcpClientForServer(); - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "async-tool" }, TestContext.Current.CancellationToken); @@ -298,7 +321,7 @@ public async Task GetTaskAsync_ImmediatelyAfterCreate_Resolves() // Strong consistency: tasks/get immediately after CreateTaskResult must resolve. await using var client = await CreateMcpClientForServer(); - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "async-tool" }, TestContext.Current.CancellationToken); @@ -328,7 +351,7 @@ public async Task CancelTask_AlreadyTerminal_AcknowledgesIdempotently() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "async-tool" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -347,7 +370,7 @@ public async Task UpdateTaskAsync_TransitionsFromInputRequired() var ct = TestContext.Current.CancellationToken; // Create an input-required task - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "input-required-tool" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -373,7 +396,7 @@ await client.UpdateTaskAsync(new UpdateTaskRequestParams } [Fact] - public async Task CallToolRawAsync_InjectsTaskCapabilityInMeta() + public async Task CallToolAsTaskAsync_InjectsTaskCapabilityInMeta() { // Verify the server receives the task extension in _meta by intercepting // the handler. The CallToolWithAlternateHandler already receives the request, @@ -381,7 +404,7 @@ public async Task CallToolRawAsync_InjectsTaskCapabilityInMeta() // by confirming the server returns a task result (which requires the capability signal). await using var client = await CreateMcpClientForServer(); - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "async-tool" }, TestContext.Current.CancellationToken); @@ -390,14 +413,14 @@ public async Task CallToolRawAsync_InjectsTaskCapabilityInMeta() } [Fact] - public async Task CallToolRawAsync_OptIn_UsesSep2575CapabilitiesEnvelope() + public async Task CallToolAsTaskAsync_OptIn_UsesSep2575CapabilitiesEnvelope() { // SEP-2663 §51: the per-request opt-in is the SEP-2575 capabilities envelope: // _meta/io.modelcontextprotocol/clientCapabilities/extensions/io.modelcontextprotocol/tasks = {} // This test pins the literal wire path so future refactors can't regress. await using var client = await CreateMcpClientForServer(); - await client.CallToolRawAsync( + await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "immediate-tool" }, TestContext.Current.CancellationToken); @@ -413,7 +436,7 @@ await client.CallToolRawAsync( } [Fact] - public async Task CallToolRawAsync_OptIn_PreservesExistingMetaSiblings() + public async Task CallToolAsTaskAsync_OptIn_PreservesExistingMetaSiblings() { // User-supplied _meta entries at the root must not be clobbered, and the SEP-2575 // envelope must be added alongside them, not in place of them. @@ -431,7 +454,7 @@ public async Task CallToolRawAsync_OptIn_PreservesExistingMetaSiblings() }, }; - await client.CallToolRawAsync( + await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "immediate-tool", @@ -452,13 +475,13 @@ await client.CallToolRawAsync( } [Fact] - public async Task CallToolRawAsync_PreservesExistingUserMeta() + public async Task CallToolAsTaskAsync_PreservesExistingUserMeta() { // Verify that user-supplied meta fields are not clobbered await using var client = await CreateMcpClientForServer(); var userMeta = new JsonObject { ["customKey"] = "customValue" }; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "immediate-tool", @@ -494,8 +517,8 @@ public async Task CallToolAsync_RespectsServerPollInterval() }); }, ct); - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "async-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "async-tool" }, cancellationToken: ct); var elapsed = DateTime.UtcNow - startTime; @@ -512,9 +535,9 @@ public async Task CallToolWithAlternateHandler_ImplicitConversion_ReturnCallTool // in the handler context — this is already tested by "immediate-tool" working correctly. await using var client = await CreateMcpClientForServer(); - var result = await client.CallToolAsync( + var result = await client.CallToolWithPollingAsync( new CallToolRequestParams { Name = "immediate-tool" }, - TestContext.Current.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken); Assert.Equal("immediate result", Assert.IsType(result.Content[0]).Text); } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs index 3f3c411f6..db051d19f 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTasksNoStoreTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -10,7 +11,7 @@ namespace ModelContextProtocol.Tests.Server; /// /// Pins the behavior when a client signals the SEP-2575 tasks opt-in via _meta but the server -/// has neither an nor a +/// has neither nor a /// configured. The expected behavior is a silent synchronous fallback: the server returns the normal /// with no Task envelope and no exception. /// @@ -35,8 +36,8 @@ public async Task ClientOptIn_NoTaskStore_FallsBackToSyncResult() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - // CallToolRawAsync always writes the SEP-2575 tasks opt-in into _meta. - var augmented = await client.CallToolRawAsync( + // CallToolAsTaskAsync always writes the SEP-2575 tasks opt-in into _meta. + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "sync-tool" }, ct); // With no task store configured, the server must complete synchronously and return @@ -54,8 +55,8 @@ public async Task ClientOptIn_NoTaskStore_CallToolAsync_StillReturnsResult() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "sync-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "sync-tool" }, cancellationToken: ct); Assert.NotNull(result); Assert.Equal("sync result", Assert.IsType(result.Content[0]).Text); diff --git a/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs index f28761812..a904c87ed 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpTaskStoreTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.AI; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -13,7 +14,7 @@ namespace ModelContextProtocol.Tests.Server; /// /// Tests for the -based auto-wiring of tools/call into tasks. -/// Verifies that setting enables task support +/// Verifies that enables task support /// for -based tools. /// public class McpTaskStoreTests : ClientServerTestBase @@ -27,23 +28,20 @@ public McpTaskStoreTests(ITestOutputHelper testOutputHelper) : base(testOutputHe protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - mcpServerBuilder.WithTools(); - - mcpServerBuilder.Services.Configure(options => - { - options.TaskStore = new InMemoryMcpTaskStore + mcpServerBuilder + .WithTools() + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, - }; - }); + }); } [Fact] - public async Task CallToolRawAsync_WithTaskCapability_ReturnsCreateTaskResult() + public async Task CallToolAsTaskAsync_WithTaskCapability_ReturnsCreateTaskResult() { await using var client = await CreateMcpClientForServer(); - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "slow-tool" }, TestContext.Current.CancellationToken); @@ -60,9 +58,9 @@ public async Task CallToolAsync_WithTaskStore_PollsToCompletion() await using var client = await CreateMcpClientForServer(); // CallToolAsync should poll until the background execution completes. - var result = await client.CallToolAsync( + var result = await client.CallToolWithPollingAsync( new CallToolRequestParams { Name = "slow-tool" }, - TestContext.Current.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Single(result.Content); @@ -75,7 +73,7 @@ public async Task CallToolAsync_WithTaskStore_FastTool_StillCreatesTask() await using var client = await CreateMcpClientForServer(); // Even a fast tool should go through the task store when the client signals capability. - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "fast-tool" }, TestContext.Current.CancellationToken); @@ -88,8 +86,8 @@ public async Task GetTaskAsync_ViaStore_ReturnsCompletedResult() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( - new CallToolRequestParams { Name = "fast-tool" }, ct); + var augmented = await client.CallToolAsTaskAsync( + new CallToolRequestParams { Name = "fast-tool" }, cancellationToken: ct); var taskId = augmented.TaskCreated!.TaskId; @@ -115,7 +113,7 @@ public async Task CancelTaskAsync_ViaStore_TransitionsToCancelled() var ct = TestContext.Current.CancellationToken; // Create a slow task that won't complete on its own - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "slow-tool" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -145,7 +143,7 @@ public async Task ToolExecution_Failure_StoresAsCompletedWithError() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "failing-tool" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -176,7 +174,7 @@ public async Task McpProtocolException_FromTool_StoresAsFailedWithJsonRpcErrorSh await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "throws-mcp-protocol" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -210,7 +208,7 @@ public async Task InputRequiredException_FromTool_FailsTaskWithActionableMessage await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "mrtr-tool" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -233,7 +231,7 @@ public async Task InputRequiredException_FromTool_FailsTaskWithActionableMessage var message = failed.Error.GetProperty("message").GetString(); Assert.NotNull(message); Assert.Contains("MRTR", message); - Assert.Contains(nameof(McpServerHandlers.CallToolWithAlternateHandler), message); + Assert.Contains("tasks", message); } [Fact] @@ -253,8 +251,8 @@ public async Task ElicitTool_ViaTask_RedirectsThroughStore() var ct = TestContext.Current.CancellationToken; // CallToolAsync will poll and resolve input requests automatically. - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "elicit-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "elicit-tool" }, cancellationToken: ct); Assert.NotNull(result); Assert.Equal("accepted", Assert.IsType(result.Content[0]).Text); @@ -279,8 +277,8 @@ public async Task SampleTool_ViaTask_RedirectsThroughStore() }); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "sample-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "sample-tool" }, cancellationToken: ct); Assert.NotNull(result); Assert.Equal("sampled response", Assert.IsType(result.Content[0]).Text); @@ -309,8 +307,8 @@ public async Task RootsTool_ViaTask_RedirectsThroughStore() }); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "roots-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "roots-tool" }, cancellationToken: ct); Assert.NotNull(result); Assert.Equal("file:///workspace,file:///other", Assert.IsType(result.Content[0]).Text); @@ -328,12 +326,10 @@ public async Task SendTaskStatusNotificationAsync_FromTool_DeliversTypedNotifica var ct = TestContext.Current.CancellationToken; await using var registration = client.RegisterNotificationHandler( - NotificationMethods.TaskStatusNotification, + TasksProtocol.NotificationTaskStatus, (notification, _) => { - var typed = JsonSerializer.Deserialize( - notification.Params, - McpJsonUtilities.DefaultOptions); + var typed = JsonSerializer.Deserialize(notification.Params, McpTasksJsonContext.Default.Options); if (typed is not null) { notifications.Writer.TryWrite(typed); @@ -342,8 +338,8 @@ public async Task SendTaskStatusNotificationAsync_FromTool_DeliversTypedNotifica return default; }); - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "notifying-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "notifying-tool" }, cancellationToken: ct); Assert.Equal("notified", Assert.IsType(result.Content[0]).Text); @@ -377,12 +373,10 @@ public async Task SendTaskStatusNotificationAsync_Failed_DeliversTypedNotificati var ct = TestContext.Current.CancellationToken; await using var registration = client.RegisterNotificationHandler( - NotificationMethods.TaskStatusNotification, + TasksProtocol.NotificationTaskStatus, (notification, _) => { - var typed = JsonSerializer.Deserialize( - notification.Params, - McpJsonUtilities.DefaultOptions); + var typed = JsonSerializer.Deserialize(notification.Params, McpTasksJsonContext.Default.Options); if (typed is FailedTaskNotificationParams) { notifications.Writer.TryWrite(typed); @@ -393,8 +387,8 @@ public async Task SendTaskStatusNotificationAsync_Failed_DeliversTypedNotificati // The tool emits a Failed notification then returns a normal result, so we isolate the // notification round-trip from the task-store's own failure handling. - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "failing-notify-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "failing-notify-tool" }, cancellationToken: ct); Assert.Equal("emitted-failed", Assert.IsType(result.Content[0]).Text); @@ -426,8 +420,8 @@ public async Task ElicitTool_ViaTask_ClientDedups_InputRequests() }); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "elicit-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "elicit-tool" }, cancellationToken: ct); // The handler should be called exactly once despite potential multiple polls Assert.Equal(1, elicitCallCount); @@ -435,7 +429,7 @@ public async Task ElicitTool_ViaTask_ClientDedups_InputRequests() } [Fact] - public async Task CallToolRawAsync_ElicitTool_ReturnsTask_ThenPollShowsInputRequired() + public async Task CallToolAsTaskAsync_ElicitTool_ReturnsTask_ThenPollShowsInputRequired() { await using var client = await CreateMcpClientForServer(new McpClientOptions { @@ -447,7 +441,7 @@ public async Task CallToolRawAsync_ElicitTool_ReturnsTask_ThenPollShowsInputRequ }); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "elicit-tool" }, ct); Assert.True(augmented.IsTask); @@ -476,7 +470,7 @@ public async Task CancelTaskAsync_AlreadyCompleted_AcknowledgesIdempotently_AndD var ct = TestContext.Current.CancellationToken; // Run a fast tool to completion via the task store. - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "fast-tool" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -516,7 +510,7 @@ public async Task CallToolAsync_ElicitHandlerThrows_PropagatesAndDoesNotLeaveCli var sw = System.Diagnostics.Stopwatch.StartNew(); var ex = await Assert.ThrowsAsync(async () => - await client.CallToolAsync(new CallToolRequestParams { Name = "elicit-tool" }, ct)); + await client.CallToolWithPollingAsync(new CallToolRequestParams { Name = "elicit-tool" }, cancellationToken: ct)); sw.Stop(); Assert.Equal("handler-failed", ex.Message); @@ -531,7 +525,7 @@ public async Task CallToolAsync_ElicitHandlerThrows_PropagatesAndDoesNotLeaveCli public async Task CallTool_WithoutTaskExtensionMeta_ReturnsCallToolResultImmediately() { // SEP-2663: "A server MUST NOT return a CreateTaskResult to a client that did not include the - // extension capability." We bypass CallToolRawAsync (which injects the marker) and send a raw + // extension capability." We bypass CallToolAsTaskAsync (which injects the marker) and send a raw // tools/call request without the io.modelcontextprotocol/tasks key in _meta. await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; @@ -561,7 +555,7 @@ public async Task ToolReturnsCallToolResultWithIsError_AsTask_StoresAsCompleted_ await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "iserror-tool" }, ct); var taskId = augmented.TaskCreated!.TaskId; @@ -602,8 +596,8 @@ public async Task MultiElicit_ViaTask_HandlerCalledExactlyOncePerUniqueKey_Acros }); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolAsync( - new CallToolRequestParams { Name = "multi-elicit-tool" }, ct); + var result = await client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "multi-elicit-tool" }, cancellationToken: ct); // Exactly two handler invocations — one per unique input request key. Assert.Equal(2, elicitCount); diff --git a/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs index 0e8693353..e751bdcdc 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -29,16 +30,13 @@ public TaskCancellationIntegrationTests(ITestOutputHelper testOutputHelper) protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - mcpServerBuilder.Services.Configure(options => - { - options.TaskStore = new InMemoryMcpTaskStore + mcpServerBuilder + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, DefaultTimeToLive = TimeSpan.FromSeconds(5), - }; - }); - - mcpServerBuilder.WithTools([McpServerTool.Create( + }) + .WithTools([McpServerTool.Create( async (CancellationToken ct) => { _toolStarted.TrySetResult(true); @@ -66,7 +64,7 @@ public async Task TaskTool_CancellationToken_FiresWhenExplicitlyCancelled() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "long-running-tool" }, ct); Assert.True(augmented.IsTask); @@ -93,7 +91,7 @@ public async Task TaskTool_CancellationToken_GetTaskShowsWorkingBeforeCancel() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "long-running-tool" }, ct); Assert.True(augmented.IsTask); @@ -130,15 +128,12 @@ public TaskCancellationConcurrencyTests(ITestOutputHelper testOutputHelper) protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - mcpServerBuilder.Services.Configure(options => - { - options.TaskStore = new InMemoryMcpTaskStore + mcpServerBuilder + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, - }; - }); - - mcpServerBuilder.WithTools([McpServerTool.Create( + }) + .WithTools([McpServerTool.Create( async (string marker, CancellationToken ct) => { TaskCompletionSource startTcs; @@ -219,14 +214,14 @@ public async Task CancelTask_OnlyCancelsTargetTask_NotOtherTasks() RegisterMarker("task2"); // Start two tasks - var result1 = await client.CallToolRawAsync( + var result1 = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "trackable-tool", Arguments = CreateMarkerArgs("task1"), }, ct); - var result2 = await client.CallToolRawAsync( + var result2 = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "trackable-tool", @@ -273,15 +268,12 @@ public TerminalTaskStatusTransitionTests(ITestOutputHelper testOutputHelper) protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - mcpServerBuilder.Services.Configure(options => - { - options.TaskStore = new InMemoryMcpTaskStore + mcpServerBuilder + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, - }; - }); - - mcpServerBuilder.WithTools([ + }) + .WithTools([ McpServerTool.Create( async (CancellationToken ct) => { @@ -316,7 +308,7 @@ public async Task CompletedTask_CancelIsAcknowledgedIdempotentlyAndStateUnchange await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "quick-tool" }, ct); Assert.True(augmented.IsTask); @@ -346,7 +338,7 @@ public async Task CompletedWithErrorTask_CancelIsAcknowledgedIdempotently() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "failing-tool" }, ct); Assert.True(augmented.IsTask); diff --git a/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs index 3c2d1d5ba..84f32267a 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs @@ -1,7 +1,10 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.Tests.Server; @@ -13,6 +16,8 @@ namespace ModelContextProtocol.Tests.Server; /// public class TaskHandlerConfigurationValidationTests : ClientServerTestBase { + private static readonly JsonTypeInfo s_createTaskResultTypeInfo = McpTasksJsonContext.Default.CreateTaskResult; + public TaskHandlerConfigurationValidationTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { #if !NET @@ -27,15 +32,18 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer options.Capabilities ??= new ServerCapabilities(); // Configure a task-augmented handler without TaskStore or any of the - // task lifecycle handlers (GetTaskHandler/UpdateTaskHandler/CancelTaskHandler). + // task lifecycle request handlers. options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => - new ValueTask>(new CreateTaskResult - { - TaskId = "orphan-task", - Status = McpTaskStatus.Working, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - }); + new ValueTask>( + new ResultOrAlternate( + new CreateTaskResult + { + TaskId = "orphan-task", + Status = McpTaskStatus.Working, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + }, + s_createTaskResultTypeInfo)); }); } diff --git a/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs index d73fe42a7..58a0e68ec 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs @@ -1,10 +1,13 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Nodes; -#pragma warning disable MCPEXP001 +#pragma warning disable MCPEXP001, MCPEXP002 namespace ModelContextProtocol.Tests.Server; @@ -29,49 +32,66 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer mcpServerBuilder.Services.Configure(options => { options.Capabilities ??= new ServerCapabilities(); + options.Capabilities.Extensions ??= new Dictionary(); + options.Capabilities.Extensions[TasksProtocol.ExtensionId] = new JsonObject(); + options.RequestHandlers ??= new List(); // CallTool always returns a CreateTaskResult with a tiny poll interval so the // test exercises the threshold in well under a second. options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => { var taskId = Guid.NewGuid().ToString("N"); - return new ValueTask>(new CreateTaskResult - { - TaskId = taskId, - Status = McpTaskStatus.InputRequired, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - PollIntervalMs = 5, - ResultType = "task", - }); + return new ValueTask>( + new ResultOrAlternate( + new CreateTaskResult + { + TaskId = taskId, + Status = McpTaskStatus.InputRequired, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + PollIntervalMs = 5, + ResultType = "task", + }, + McpTasksJsonContext.Default.CreateTaskResult)); }; // GetTask always reports InputRequired with NO outstanding input requests — the // misbehaving-server condition the stuck-detector exists to break out of. - options.Handlers.GetTaskHandler = (context, cancellationToken) => + options.RequestHandlers.Add(new McpServerRequestHandler { - Interlocked.Increment(ref _pollCount); - - return new ValueTask(new InputRequiredTaskResult + Method = TasksProtocol.MethodTasksGet, + Handler = (request, cancellationToken) => { - TaskId = context.Params!.TaskId, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - PollIntervalMs = 5, - InputRequests = new Dictionary(), - ResultType = "complete", - }); - }; - - // CancelTask must succeed since the client issues a best-effort cancel when it - // gives up; otherwise the cancel failure would mask the real exception. - options.Handlers.CancelTaskHandler = (context, cancellationToken) => - new ValueTask(new CancelTaskResult { ResultType = "complete" }); + var requestParams = JsonSerializer.Deserialize(request.Params, McpTasksJsonContext.Default.Options) + ?? throw new McpProtocolException("Missing params for tasks/get", McpErrorCode.InvalidParams); + + Interlocked.Increment(ref _pollCount); + + return new ValueTask(JsonSerializer.SerializeToNode(new InputRequiredTaskResult + { + TaskId = requestParams.TaskId, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + PollIntervalMs = 5, + InputRequests = new Dictionary(), + ResultType = "complete", + }, McpTasksJsonContext.Default.Options)); + }, + }); + + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = TasksProtocol.MethodTasksCancel, + Handler = (request, cancellationToken) => + new ValueTask(JsonSerializer.SerializeToNode(new CancelTaskResult { ResultType = "complete" }, McpTasksJsonContext.Default.Options)), + }); - // UpdateTask is never invoked in this scenario (there are no input requests to resolve) - // but must be present so the handler-configuration validation passes. - options.Handlers.UpdateTaskHandler = (context, cancellationToken) => - new ValueTask(new UpdateTaskResult { ResultType = "complete" }); + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = TasksProtocol.MethodTasksUpdate, + Handler = (request, cancellationToken) => + new ValueTask(JsonSerializer.SerializeToNode(new UpdateTaskResult { ResultType = "complete" }, McpTasksJsonContext.Default.Options)), + }); }); } @@ -82,7 +102,7 @@ public async Task CallToolAsync_TaskStuckInInputRequired_WithoutNewRequests_Thro var ct = TestContext.Current.CancellationToken; var ex = await Assert.ThrowsAsync(async () => - await client.CallToolAsync(new CallToolRequestParams { Name = "any-tool" }, ct)); + await client.CallToolWithPollingAsync(new CallToolRequestParams { Name = "any-tool" }, cancellationToken: ct)); Assert.Contains(McpTaskStatus.InputRequired.ToString(), ex.Message); Assert.Contains("consecutive polls", ex.Message); @@ -93,18 +113,15 @@ public async Task CallToolAsync_TaskStuckInInputRequired_WithoutNewRequests_Thro [Fact] public async Task CallToolAsync_StuckDetector_HonorsConfiguredThreshold() { - // Verifies McpClientOptions.MaxConsecutiveStuckPolls is plumbed into PollTaskToCompletionAsync: + // Verifies CallToolWithPollingAsync plumbs the explicit threshold into PollTaskToCompletionAsync: // a smaller configured threshold is surfaced verbatim in the McpException message. const int CustomThreshold = 3; - await using var client = await CreateMcpClientForServer(new McpClientOptions - { - MaxConsecutiveStuckPolls = CustomThreshold, - }); + await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; var ex = await Assert.ThrowsAsync(async () => - await client.CallToolAsync(new CallToolRequestParams { Name = "any-tool" }, ct)); + await client.CallToolWithPollingAsync(new CallToolRequestParams { Name = "any-tool" }, maxConsecutiveStuckPolls: CustomThreshold, cancellationToken: ct)); // The message embeds the configured threshold, which is the strongest signal that the // option value (not the 60-default constant) is what governed the loop. @@ -112,19 +129,4 @@ public async Task CallToolAsync_StuckDetector_HonorsConfiguredThreshold() Assert.Equal(CustomThreshold, _pollCount); } - [Theory] - [InlineData(0)] - [InlineData(-1)] - [InlineData(int.MinValue)] - public void McpClientOptions_MaxConsecutiveStuckPolls_RejectsNonPositive(int value) - { - var options = new McpClientOptions(); - Assert.Throws(() => options.MaxConsecutiveStuckPolls = value); - } - - [Fact] - public void McpClientOptions_MaxConsecutiveStuckPolls_DefaultsTo60() - { - Assert.Equal(60, new McpClientOptions().MaxConsecutiveStuckPolls); - } } diff --git a/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs index 7f9790526..5379dac8c 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs @@ -1,3 +1,4 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -6,8 +7,6 @@ using System.Text.Json; using System.Text.Json.Nodes; -#pragma warning disable MCPEXP001 - namespace ModelContextProtocol.Tests.Server; /// @@ -32,15 +31,12 @@ public TaskProtocolGatingTests(ITestOutputHelper outputHelper) protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - mcpServerBuilder.Services.Configure(options => - { - options.TaskStore = new InMemoryMcpTaskStore + mcpServerBuilder + .WithTasks(new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, - }; - }); - - mcpServerBuilder.WithTools([McpServerTool.Create( + }) + .WithTools([McpServerTool.Create( async (string input, CancellationToken ct) => { await Task.Delay(50, ct); @@ -68,7 +64,7 @@ private static JsonObject CreateForgedTaskOptInMeta() => { [ExtensionsKey] = new JsonObject { - [McpExtensions.Tasks] = new JsonObject(), + [TasksProtocol.ExtensionId] = new JsonObject(), }, }, }; @@ -115,7 +111,7 @@ public async Task LegacyClient_CallToolRaw_ReturnsDirectResult_NoTaskCreated() await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolRawAsync( + var result = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", @@ -135,7 +131,7 @@ public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_ServerReturnsDire // Forge a SEP-2575 capabilities envelope carrying the tasks extension opt-in on a legacy // request. The server must still refuse to create a task because the per-request protocol // version is not the 2026-07-28 protocol. - var result = await client.CallToolRawAsync( + var result = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", @@ -157,10 +153,9 @@ public async Task LegacyClient_RawTasksGetRequest_ReturnsMethodNotFound() // gates tasks/* to the 2026-07-28 protocol and must reject this legacy request with MethodNotFound. var request = new JsonRpcRequest { - Method = RequestMethods.TasksGet, + Method = TasksProtocol.MethodTasksGet, Params = JsonSerializer.SerializeToNode( - new GetTaskRequestParams { TaskId = "some-task-id" }, - McpJsonUtilities.DefaultOptions), + new GetTaskRequestParams { TaskId = "some-task-id" }, McpTasksJsonContext.Default.Options), }; var ex = await Assert.ThrowsAsync(async () => @@ -176,7 +171,7 @@ public async Task July2026ProtocolClient_CallToolRaw_CreatesTask() await using var client = await CreateMcpClientForServer(); var ct = TestContext.Current.CancellationToken; - var result = await client.CallToolRawAsync( + var result = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "test-tool", diff --git a/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs index 719bd5d19..97221b2f9 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs @@ -1,22 +1,26 @@ +using ModelContextProtocol.Extensions.Tasks; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Runtime.InteropServices; using System.Text.Json; +using System.Text.Json.Serialization.Metadata; #pragma warning disable MCPEXP001 namespace ModelContextProtocol.Tests.Server; /// -/// Verifies that when both and +/// Verifies that when both and /// are configured and the handler returns /// (IsTask = true), the store's pre-created task is failed with a /// clear error rather than being orphaned in forever. /// public class TaskStoreOrphanedTaskTests : ClientServerTestBase { + private static readonly JsonTypeInfo s_createTaskResultTypeInfo = McpTasksJsonContext.Default.CreateTaskResult; + public TaskStoreOrphanedTaskTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { #if !NET @@ -26,21 +30,25 @@ public TaskStoreOrphanedTaskTests(ITestOutputHelper testOutputHelper) : base(tes protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { + mcpServerBuilder.WithTasks(new InMemoryMcpTaskStore()); + mcpServerBuilder.Services.Configure(options => { options.Capabilities ??= new ServerCapabilities(); - options.TaskStore = new InMemoryMcpTaskStore(); - // Returning IsTask = true here while TaskStore is also configured is the + // Returning IsTask = true here while the tasks extension is also configured is the // misconfiguration the server must guard against. options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => - new ValueTask>(new CreateTaskResult - { - TaskId = "user-task", - Status = McpTaskStatus.Working, - CreatedAt = DateTimeOffset.UtcNow, - LastUpdatedAt = DateTimeOffset.UtcNow, - }); + new ValueTask>( + new ResultOrAlternate( + new CreateTaskResult + { + TaskId = "user-task", + Status = McpTaskStatus.Working, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + }, + s_createTaskResultTypeInfo)); }); } @@ -51,7 +59,7 @@ public async Task TaskStoreAndHandler_BothCreatingTasks_FailsStoreTaskWithClearE var ct = TestContext.Current.CancellationToken; // The store's task is created synchronously and its taskId returned to the client. - var augmented = await client.CallToolRawAsync( + var augmented = await client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "anything" }, ct); Assert.True(augmented.IsTask); @@ -76,7 +84,7 @@ public async Task TaskStoreAndHandler_BothCreatingTasks_FailsStoreTaskWithClearE var message = failed.Error.GetProperty("message").GetString(); Assert.NotNull(message); - Assert.Contains(nameof(McpServerOptions.TaskStore), message); + Assert.Contains(nameof(IMcpTaskStore), message); Assert.Contains(nameof(McpServerHandlers.CallToolWithAlternateHandler), message); } } From 87064d3cb577d594f54e4129c6e907fc14fa14cb Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Thu, 25 Jun 2026 22:58:32 -0700 Subject: [PATCH 03/16] Restore accurate illustrative comments in TasksExtension sample - Restore the inline-result branch comment on the !raw.IsTask path - Restore the CompletedTaskResult JsonElement deserialization comment - Restore the RunReport sleep-vs-real-work comment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/TasksExtension/Program.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/samples/TasksExtension/Program.cs b/samples/TasksExtension/Program.cs index 905c30a64..50ddf0d03 100644 --- a/samples/TasksExtension/Program.cs +++ b/samples/TasksExtension/Program.cs @@ -41,6 +41,8 @@ var raw = await client.CallToolAsTaskAsync(new CallToolRequestParams { Name = "run-report" }); if (!raw.IsTask) { + // Either the server doesn't advertise the tasks extension or it chose to run the call + // synchronously despite the client opt-in. Surface the inline result and stop. Console.WriteLine($" result (inline): {((TextContentBlock)raw.Result!.Content[0]).Text}"); return; } @@ -64,6 +66,7 @@ switch (state) { case CompletedTaskResult completed: + // The Result property carries the wrapped CallToolResult as a raw JsonElement. var callToolResult = completed.Result.Deserialize()!; Console.WriteLine($" task completed after {pollCount} poll(s): {((TextContentBlock)callToolResult.Content[0]).Text}"); return; @@ -91,6 +94,8 @@ internal static class SlowTools [Description("Runs a short simulated report and returns when it's done.")] public static async Task RunReport(CancellationToken cancellationToken) { + // Real-world workloads would do meaningful work here; we just sleep so the polling + // path is observable in the console output. await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); return "report ready"; } From 1214556707e0477ea3bb5829ff7948045b552dc7 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:13:32 -0400 Subject: [PATCH 04/16] Update package READMEs as part of the release process (#1683) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com> --- .github/release-process.md | 4 +- .github/skills/prepare-release/SKILL.md | 20 +++- .../references/readme-content.md | 94 +++++++++++++++++++ .../references/readme-snippets.md | 18 ++-- .github/skills/publish-release/SKILL.md | 22 +++-- README.md | 4 +- ...odelContextProtocol.Extensions.Apps.csproj | 2 +- src/PACKAGE.md | 14 ++- 8 files changed, 152 insertions(+), 26 deletions(-) create mode 100644 .github/skills/prepare-release/references/readme-content.md diff --git a/.github/release-process.md b/.github/release-process.md index 41f772759..5c973fca7 100644 --- a/.github/release-process.md +++ b/.github/release-process.md @@ -12,11 +12,13 @@ The following process is used when publishing new releases to NuGet.org. From a local clone of the repository, use Copilot CLI to invoke the `prepare-release` skill. The skill assesses the semantic version, bumps the version in [`src/Directory.Build.props`](../src/Directory.Build.props), runs API compatibility checks, reviews documentation, drafts release notes, and creates a pull request with all release artifacts. +As part of Step 9 (documentation review), the skill also updates the shared embedded NuGet README (`src/PACKAGE.md`) -- adding any newly introduced packages to the package-list closure, applying the correct badge style (`nuget/vpre` for a prerelease series or `nuget/v` for a stable release), adding a release-notes link pointing to the tag being created, and syncing the same closure changes to the root `README.md`. + Review the PR, request changes if needed, and merge when ready. ## 3. Publish the release -After the prepare-release PR is merged, invoke the `publish-release` skill. The skill checks for any late-arriving PRs that could affect the release, refreshes the release notes, and creates a **draft** GitHub release. +After the prepare-release PR is merged, invoke the `publish-release` skill. The skill checks for any late-arriving PRs that could affect the release, refreshes the release notes, re-runs the README content checklist (confirming package closure, badge style, and release-notes link), and creates a **draft** GitHub release. Review the draft release on GitHub, check 'Set as a pre-release' if appropriate, and click 'Publish release'. diff --git a/.github/skills/prepare-release/SKILL.md b/.github/skills/prepare-release/SKILL.md index f881332a1..29bc58394 100644 --- a/.github/skills/prepare-release/SKILL.md +++ b/.github/skills/prepare-release/SKILL.md @@ -130,13 +130,25 @@ Generate a human-readable diff of the public API surface between the previous re Review repository documentation for changes needed to compensate for or adapt to this release: -1. **NuGet package READMEs** — Validate that code samples in `README.md` and `src/PACKAGE.md` compile against the current SDK. Follow [references/readme-snippets.md](references/readme-snippets.md) for the validation procedure. Propose fixes for any API mismatches. -2. **Conceptual documentation** — Review `docs/` for content affected by the changes in this release. Update references to changed APIs, new features, or removed functionality. -3. **Versioning documentation** — If the release introduces new versioning-relevant policies (new experimental APIs, obsoletion changes), verify `docs/versioning.md` reflects them. -4. **Changelogs** — If the repository contains changelog files (e.g., `CHANGELOG.md`), update them with the release information. If no changelogs exist, skip this sub-step and note it in the summary. +1. **NuGet package READMEs** -- Run the README content checklist from [references/readme-content.md](references/readme-content.md) and validate code samples: + a. **Content checklist** -- Open `src/PACKAGE.md` and verify each item in the checklist: + - **Package-list closure**: every shipping SDK package is listed. If a new package was introduced in this release, add it now. Use non-counting phrasing -- do not say "N main packages". + - **Badge strategy**: all package badges use `nuget/vpre` for a prerelease series or `nuget/v` for a stable release. Switch all badges together if the release type has changed. + - **Release-notes link**: add or update the link to `https://github.com/modelcontextprotocol/csharp-sdk/releases/tag/v{version}` for the confirmed release version. The tag does not yet exist at prepare time; the link is forward-referencing and resolves when the GitHub release is published. + - **Root README.md sync**: mirror any package-list closure changes in the root `README.md`. + - **Other salient content**: descriptions, getting-started links, version-specific notes. + b. **Snippet validation** -- Validate that `csharp`-fenced code blocks in `src/PACKAGE.md` and `README.md` compile against the current SDK. Follow [references/readme-snippets.md](references/readme-snippets.md) for the full procedure. Propose fixes for any API mismatches. +2. **Conceptual documentation** -- Review `docs/` for content affected by the changes in this release. Update references to changed APIs, new features, or removed functionality. +3. **Versioning documentation** -- If the release introduces new versioning-relevant policies (new experimental APIs, obsoletion changes), verify `docs/versioning.md` reflects them. +4. **Changelogs** -- If the repository contains changelog files (e.g., `CHANGELOG.md`), update them with the release information. If no changelogs exist, skip this sub-step and note it in the summary. Stage all documentation changes for inclusion in the release commit. +**Edge Cases for README updates:** +- **New package introduced** -- Add it to the package-list closure in `src/PACKAGE.md` and `README.md`. Use the package's `` from its `.csproj` as the short description. +- **Release type changes (prerelease to stable or vice versa)** -- Switch all package badges between `nuget/vpre` and `nuget/v` together. +- **Release tag does not yet exist at prepare time** -- The release-notes link is forward-referencing; it is verified to resolve during the publish-release step. + ### Step 10: Draft Release Notes Compose the release notes that will appear in the PR description and serve as the foundation for the **publish-release** skill. This is a draft — the final release notes will be refreshed when the GitHub release is created. diff --git a/.github/skills/prepare-release/references/readme-content.md b/.github/skills/prepare-release/references/readme-content.md new file mode 100644 index 000000000..e7fa16aed --- /dev/null +++ b/.github/skills/prepare-release/references/readme-content.md @@ -0,0 +1,94 @@ +# README Content Checklist + +This reference describes what to review and update in the shared embedded NuGet README +(`src/PACKAGE.md`) and the root repository README (`README.md`) as part of every release. + +## The Shared Embedded README + +All SDK packages embed the **same** README file: `src/PACKAGE.md`. + +Each project packs it identically: + +```xml + +README.md +``` + +Updating `src/PACKAGE.md` updates every package's nuget.org README at once. +There are no per-package README files; `src/ModelContextProtocol.Core/README.md` and +similar paths do not exist. + +## Checklist + +### 1. Package-list closure + +Every shipping SDK package must be listed in the packages section of `src/PACKAGE.md`, +including packages introduced after the initial SDK launch and including the package +being viewed in its own embedded README on nuget.org. + +Current packages to list: +- `ModelContextProtocol.Core` +- `ModelContextProtocol` +- `ModelContextProtocol.AspNetCore` +- `ModelContextProtocol.Extensions.Apps` + +Avoid counting phrases such as "three main packages" -- they become stale when packages +are added. Use a non-counting closure such as "The SDK packages are:" instead. + +When a new package is introduced, add it to the list in both `src/PACKAGE.md` and the +root `README.md` (see section below). + +### 2. Badge strategy + +Each package entry carries a nuget.org version badge. The correct badge endpoint depends +on the release type: + +| Release type | Badge endpoint | Example | +|---|---|---| +| Prerelease series (e.g., `2.0.0-preview.*`) | `nuget/vpre/{package}` | `https://img.shields.io/nuget/vpre/ModelContextProtocol.svg` | +| Stable release | `nuget/v/{package}` | `https://img.shields.io/nuget/v/ModelContextProtocol.svg` | + +`nuget/v` renders only the latest stable version and shows nothing (or a placeholder) +during a prerelease-only series. `nuget/vpre` renders the latest version including +prereleases. Switch all package badges together when the release type changes. + +Verify every badge in `src/PACKAGE.md` uses the correct endpoint for this release. + +### 3. Release-notes link + +`src/PACKAGE.md` must contain one statement linking to the release notes for the +current version: + +```markdown +See the [release notes](https://github.com/modelcontextprotocol/csharp-sdk/releases/tag/v{version}) +for what's new in this version. +``` + +Replace `{version}` with the exact version being released, including any prerelease +suffix (e.g., `2.0.0-preview.2`). + +At prepare time the tag does not yet exist; the link is forward-referencing. The link +resolves once the GitHub release is published during the publish-release step. + +Update this link for every release -- it must point to the tag being created, not a +prior release. + +### 4. Root README.md sync + +The root `README.md` (the GitHub repo readme, NOT packed into packages) has its own +package-list section. Keep it aligned with `src/PACKAGE.md`: +- Same set of packages listed +- Same non-counting closure phrasing +- Badge strategy in `README.md` may also be updated for consistency, but the root + README is visible on GitHub (not nuget.org) so the badge choice is less critical + +## Salient content to review + +Beyond the structural checks above, read the current `src/PACKAGE.md` for any content +that has become stale due to changes in this release: + +- Package descriptions (are they still accurate?) +- Getting-started links (do they resolve and describe the current API?) +- Code samples, if any (do they compile against the current SDK? see + [readme-snippets.md](readme-snippets.md)) +- Any version-specific notes from a prior release that should be removed or updated diff --git a/.github/skills/prepare-release/references/readme-snippets.md b/.github/skills/prepare-release/references/readme-snippets.md index 7d2cd4563..a91efeeff 100644 --- a/.github/skills/prepare-release/references/readme-snippets.md +++ b/.github/skills/prepare-release/references/readme-snippets.md @@ -4,19 +4,21 @@ This reference describes how to validate that C# code samples in README files co ## Which READMEs to Validate -Validate code samples from the **package README** files — these are shipped with NuGet packages and are the primary documentation users see: +Validate code samples from the **package README** and the root repository README: -| README | Package | -|--------|---------| -| `README.md` (root) | ModelContextProtocol | -| `src/ModelContextProtocol.Core/README.md` | ModelContextProtocol.Core | -| `src/ModelContextProtocol.AspNetCore/README.md` | ModelContextProtocol.AspNetCore | +| README | Notes | +|--------|-------| +| `src/PACKAGE.md` | The single shared embedded README packed into every SDK package. This is the primary documentation users see on nuget.org. | +| `README.md` (root) | The GitHub repository readme. Not packed into packages, but visible to developers browsing the repo. | -Sample README files (`samples/*/README.md`) are excluded — the samples themselves are buildable projects and are validated by CI. +All SDK packages embed `src/PACKAGE.md` via their `.csproj` files. There are no per-package +README files; paths such as `src/ModelContextProtocol.Core/README.md` do not exist. + +Sample README files (`samples/*/README.md`) are excluded -- the samples themselves are buildable projects and are validated by CI. ## What to Extract -Extract only fenced code blocks tagged as `csharp` (` ```csharp `). Skip blocks tagged as plain ` ``` ` (shell commands, install instructions) or any other language. +Extract only fenced code blocks tagged as `csharp` (` ```csharp `) from `src/PACKAGE.md` and `README.md`. Skip blocks tagged as plain ` ``` ` (shell commands, install instructions) or any other language. ### Handling Incomplete Snippets diff --git a/.github/skills/publish-release/SKILL.md b/.github/skills/publish-release/SKILL.md index 0706cd361..173bb2064 100644 --- a/.github/skills/publish-release/SKILL.md +++ b/.github/skills/publish-release/SKILL.md @@ -69,14 +69,24 @@ Re-categorize all PRs in the commit range (including any new ones from Step 3). 3. **Re-attribute** co-authors for any new PRs by harvesting `Co-authored-by` trailers from all commits in each PR. 4. **Update acknowledgements** to include contributors from new PRs. -### Step 5: Validate README Code Samples +### Step 5: Review README and Validate Code Samples -Verify that all C# code samples in the package README files compile against the current SDK at the merge commit. Follow the [README validation guide](../prepare-release/references/readme-snippets.md) for the full procedure. +Re-run the README content checklist from [../prepare-release/references/readme-content.md](../prepare-release/references/readme-content.md) and validate code samples against the current SDK at the merge commit. Produce final suggestions before the release is created. -1. Extract `csharp`-fenced code blocks from `README.md` and `src/PACKAGE.md` -2. Create a temporary test project at `tests/ReadmeSnippetValidation/` -3. Build and report results -4. Delete the temporary project +1. **Content checklist** -- Open `src/PACKAGE.md` and verify: + - **Package-list closure**: every shipping SDK package is listed. If a new package was introduced after prepare-release ran, it may be missing. + - **Badge strategy**: all badges use `nuget/vpre` for a prerelease or `nuget/v` for a stable release. Verify the badge style is correct for this release type. + - **Release-notes link**: the link points to `https://github.com/modelcontextprotocol/csharp-sdk/releases/tag/v{version}` for the tag being created. The tag is about to exist -- verify the URL is correct. + - **Root README.md sync**: confirm the root `README.md` package list is aligned. +2. **Snippet validation** -- Extract `csharp`-fenced code blocks from `src/PACKAGE.md` and `README.md`, build the temporary test project, and report results. Follow [../prepare-release/references/readme-snippets.md](../prepare-release/references/readme-snippets.md) for the full procedure. +3. **Delete** the temporary project after validation. + +If issues are found, present them to the user with proposed fixes. Any fixes must be applied as a separate commit before the draft release is created. + +**Edge Cases:** +- **Stale package closure** -- A package introduced between prepare-release and now may not be listed. Add it to `src/PACKAGE.md` and `README.md`. +- **Wrong badge style for the release type** -- Switch all badges together from `nuget/vpre` to `nuget/v` (or vice versa) if the prepare-release step used the wrong style. +- **Missing or incorrect release-notes link** -- Correct the link to target the exact tag being created, including any prerelease suffix. ### Step 6: Review Sections diff --git a/README.md b/README.md index f0ab4a0ac..fd2ed2ae2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The official C# SDK for the [Model Context Protocol](https://modelcontextprotoco ## Packages -This SDK consists of three main packages: +The SDK packages are: - **[ModelContextProtocol.Core](https://www.nuget.org/packages/ModelContextProtocol.Core)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Core.svg)](https://www.nuget.org/packages/ModelContextProtocol.Core) - For projects that only need to use the client or low-level server APIs and want the minimum number of dependencies. @@ -14,6 +14,8 @@ This SDK consists of three main packages: - **[ModelContextProtocol.AspNetCore](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.AspNetCore.svg)](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore) - The library for HTTP-based MCP servers. References `ModelContextProtocol`. +- **[ModelContextProtocol.Extensions.Apps](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Extensions.Apps.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps) - MCP Apps extension for building interactive UI applications that render inside MCP hosts. + ## Getting Started To get started, see the [Getting Started](https://csharp.sdk.modelcontextprotocol.io/concepts/getting-started.html) guide in the conceptual documentation for installation instructions, package-selection guidance, and complete examples for both clients and servers. diff --git a/src/ModelContextProtocol.Extensions.Apps/ModelContextProtocol.Extensions.Apps.csproj b/src/ModelContextProtocol.Extensions.Apps/ModelContextProtocol.Extensions.Apps.csproj index 1d7d0dd8a..bd14370d5 100644 --- a/src/ModelContextProtocol.Extensions.Apps/ModelContextProtocol.Extensions.Apps.csproj +++ b/src/ModelContextProtocol.Extensions.Apps/ModelContextProtocol.Extensions.Apps.csproj @@ -5,7 +5,7 @@ true true ModelContextProtocol.Extensions.Apps - MCP Apps extension for the .NET Model Context Protocol (MCP) SDK + MCP Apps extension for building interactive UI applications that render inside MCP hosts. README.md $(NoWarn);MCPEXP001;MCPEXP003 diff --git a/src/PACKAGE.md b/src/PACKAGE.md index d849a8f83..1495cd1fb 100644 --- a/src/PACKAGE.md +++ b/src/PACKAGE.md @@ -1,18 +1,22 @@ # MCP C# SDK -[![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.svg)](https://www.nuget.org/packages/ModelContextProtocol) +[![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.svg)](https://www.nuget.org/packages/ModelContextProtocol) The official C# SDK for the [Model Context Protocol](https://modelcontextprotocol.io/), enabling .NET applications, services, and libraries to implement and interact with MCP clients and servers. Please visit the [API documentation](https://csharp.sdk.modelcontextprotocol.io/api/ModelContextProtocol.html) for more details on available functionality. +See the [release notes](https://github.com/modelcontextprotocol/csharp-sdk/releases/tag/v2.0.0-preview.1) for what's new in this version. + ## Packages -This SDK consists of three main packages: +The SDK packages are: + +- **[ModelContextProtocol.Core](https://www.nuget.org/packages/ModelContextProtocol.Core)** [![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.Core.svg)](https://www.nuget.org/packages/ModelContextProtocol.Core) - For projects that only need to use the client or low-level server APIs and want the minimum number of dependencies. -- **[ModelContextProtocol.Core](https://www.nuget.org/packages/ModelContextProtocol.Core)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Core.svg)](https://www.nuget.org/packages/ModelContextProtocol.Core) - For projects that only need to use the client or low-level server APIs and want the minimum number of dependencies. +- **[ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol)** [![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.svg)](https://www.nuget.org/packages/ModelContextProtocol) - The main package with hosting and dependency injection extensions. References `ModelContextProtocol.Core`. This is the right fit for most projects that don't need HTTP server capabilities. -- **[ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.svg)](https://www.nuget.org/packages/ModelContextProtocol) - The main package with hosting and dependency injection extensions. References `ModelContextProtocol.Core`. This is the right fit for most projects that don't need HTTP server capabilities. +- **[ModelContextProtocol.AspNetCore](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore)** [![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.AspNetCore.svg)](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore) - The library for HTTP-based MCP servers. References `ModelContextProtocol`. -- **[ModelContextProtocol.AspNetCore](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.AspNetCore.svg)](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore) - The library for HTTP-based MCP servers. References `ModelContextProtocol`. +- **[ModelContextProtocol.Extensions.Apps](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps)** [![NuGet version](https://img.shields.io/nuget/vpre/ModelContextProtocol.Extensions.Apps.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps) - MCP Apps extension for building interactive UI applications that render inside MCP hosts. ## Getting Started From 84eb6942eb0c713bc8ae5ed2e2b40f71f18106fe Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:15:45 -0400 Subject: [PATCH 05/16] Add DeferChangedEvents() to McpServerPrimitiveCollection for batched change notifications (#1689) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jeffhandley <1031940+jeffhandley@users.noreply.github.com> Co-authored-by: Jeff Handley Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Server/McpServerPrimitiveCollection.cs | 88 ++- .../McpServerBuilderExtensionsPromptsTests.cs | 44 ++ ...cpServerBuilderExtensionsResourcesTests.cs | 44 ++ .../McpServerBuilderExtensionsToolsTests.cs | 44 ++ .../McpServerPrimitiveCollectionTests.cs | 595 ++++++++++++++++++ 5 files changed, 814 insertions(+), 1 deletion(-) create mode 100644 tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs diff --git a/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs b/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs index e126fb13d..aa281989b 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs @@ -12,6 +12,15 @@ public class McpServerPrimitiveCollection : ICollection, IReadOnlyCollecti /// Concurrent dictionary of primitives, indexed by their names. private readonly ConcurrentDictionary _primitives; + /// Lock protecting and . + private readonly object _deferralLock = new(); + + /// Depth counter for active scopes. Positive means notifications are deferred. + private int _activeDeferralScopes; + + /// Whether a change occurred while notifications were deferred. + private bool _hasDeferredChangeEvents; + /// /// Initializes a new instance of the class. /// @@ -33,8 +42,85 @@ public McpServerPrimitiveCollection(IEqualityComparer? keyComparer = nul /// Gets a value that indicates whether there are any primitives in the collection. public bool IsEmpty => _primitives.IsEmpty; + /// + /// Begins a deferred-change scope. notifications are suppressed + /// until the returned scope is disposed, at which point a single notification is raised + /// if any mutation occurred during the scope. Multiple scopes may be active simultaneously; + /// the notification fires once all active scopes have been disposed. + /// + /// An that ends the deferral scope when disposed. + /// + /// The scope is exception-safe: even if an exception is thrown inside a using block, + /// the deferral is ended on dispose. If any mutation occurred before the exception, a single + /// notification is raised. + /// + /// Mutations from any thread during an open scope are coalesced. A single + /// notification fires on the thread that disposes the last active scope, only if at least one + /// mutation occurred. All deferral state transitions are guarded by an internal lock, so + /// concurrent mutations and concurrent scope disposal are both safe. Disposing the same scope + /// instance more than once is safe and has no additional effect. + /// + /// + public IDisposable DeferChangedEvents() + { + lock (_deferralLock) + { + _activeDeferralScopes++; + } + return new ChangeDeferralScope(this); + } + /// Raises if there are registered handlers. - protected void RaiseChanged() => Changed?.Invoke(this, EventArgs.Empty); + /// + /// If a scope is active, the notification is deferred until all + /// active scopes are disposed. Derived types that override mutation methods and call + /// will automatically participate in deferral. + /// + protected void RaiseChanged() + { + lock (_deferralLock) + { + if (_activeDeferralScopes > 0) + { + _hasDeferredChangeEvents = true; + return; + } + } + + Changed?.Invoke(this, EventArgs.Empty); + } + + private void EndDeferral() + { + bool raise; + lock (_deferralLock) + { + raise = --_activeDeferralScopes == 0 && _hasDeferredChangeEvents; + if (raise) + { + _hasDeferredChangeEvents = false; + } + } + + if (raise) + { + Changed?.Invoke(this, EventArgs.Empty); + } + } + + private sealed class ChangeDeferralScope : IDisposable + { + private McpServerPrimitiveCollection? _collection; + + public ChangeDeferralScope(McpServerPrimitiveCollection collection) => + _collection = collection; + + public void Dispose() + { + McpServerPrimitiveCollection? collection = Interlocked.Exchange(ref _collection, null); + collection?.EndDeferral(); + } + } /// Gets the with the specified from the collection. /// The name of the primitive to retrieve. diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs index 4182957cf..03234f6e3 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs @@ -173,6 +173,50 @@ public async Task Can_Be_Notified_Of_Prompt_Changes() Assert.DoesNotContain(prompts, t => t.Name == "NewPrompt"); } + [Fact] + public async Task DeferChangedEvents_BatchAddPrompts_EmitsExactlyOneNotification() + { + // Under the 2026-07-28 protocol, list-changed notifications are delivered only over a + // subscriptions/listen stream. Pin the legacy revision to test the session-wide broadcast. + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + }); + + var serverOptions = ServiceProvider.GetRequiredService>().Value; + var serverPrompts = serverOptions.PromptCollection; + Assert.NotNull(serverPrompts); + + int notificationCount = 0; + var firstNotification = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using (client.RegisterNotificationHandler(NotificationMethods.PromptListChangedNotification, (notification, cancellationToken) => + { + if (Interlocked.Increment(ref notificationCount) == 1) + { + firstNotification.TrySetResult(true); + } + return default; + })) + { + using (serverPrompts.DeferChangedEvents()) + { + serverPrompts.Add(McpServerPrompt.Create([McpServerPrompt(Name = "BatchPrompt1")] () => "1")); + serverPrompts.Add(McpServerPrompt.Create([McpServerPrompt(Name = "BatchPrompt2")] () => "2")); + serverPrompts.Add(McpServerPrompt.Create([McpServerPrompt(Name = "BatchPrompt3")] () => "3")); + } + + await firstNotification.Task.WaitAsync(TestContext.Current.CancellationToken); + + // Do a round-trip so that any second (erroneous) notification has time to arrive. + var prompts = await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(prompts, t => t.Name == "BatchPrompt1"); + Assert.Contains(prompts, t => t.Name == "BatchPrompt2"); + Assert.Contains(prompts, t => t.Name == "BatchPrompt3"); + + Assert.Equal(1, notificationCount); + } + } + [Fact] public async Task AttributeProperties_Propagated() { diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs index d8fd0a231..663c06a7f 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs @@ -207,6 +207,50 @@ public async Task Can_Be_Notified_Of_Resource_Changes() Assert.DoesNotContain(resources, t => t.Name == "NewResource"); } + [Fact] + public async Task DeferChangedEvents_BatchAddResources_EmitsExactlyOneNotification() + { + // Under the 2026-07-28 protocol, list-changed notifications are delivered only over a + // subscriptions/listen stream. Pin the legacy revision to test the session-wide broadcast. + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + }); + + var serverOptions = ServiceProvider.GetRequiredService>().Value; + var serverResources = serverOptions.ResourceCollection; + Assert.NotNull(serverResources); + + int notificationCount = 0; + var firstNotification = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using (client.RegisterNotificationHandler(NotificationMethods.ResourceListChangedNotification, (notification, cancellationToken) => + { + if (Interlocked.Increment(ref notificationCount) == 1) + { + firstNotification.TrySetResult(true); + } + return default; + })) + { + using (serverResources.DeferChangedEvents()) + { + serverResources.Add(McpServerResource.Create([McpServerResource(Name = "BatchResource1", UriTemplate = "test://batch1")] () => "1")); + serverResources.Add(McpServerResource.Create([McpServerResource(Name = "BatchResource2", UriTemplate = "test://batch2")] () => "2")); + serverResources.Add(McpServerResource.Create([McpServerResource(Name = "BatchResource3", UriTemplate = "test://batch3")] () => "3")); + } + + await firstNotification.Task.WaitAsync(TestContext.Current.CancellationToken); + + // Do a round-trip so that any second (erroneous) notification has time to arrive. + var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(resources, t => t.Name == "BatchResource1"); + Assert.Contains(resources, t => t.Name == "BatchResource2"); + Assert.Contains(resources, t => t.Name == "BatchResource3"); + + Assert.Equal(1, notificationCount); + } + } + [Fact] public async Task AttributeProperties_Propagated() { diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs index 5359ec73c..154c40f94 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs @@ -232,6 +232,50 @@ public async Task Can_Be_Notified_Of_Tool_Changes() Assert.DoesNotContain(tools, t => t.Name == "NewTool"); } + [Fact] + public async Task DeferChangedEvents_BatchAddTools_EmitsExactlyOneNotification() + { + // Under the 2026-07-28 protocol, list-changed notifications are delivered only over a + // subscriptions/listen stream. Pin the legacy revision to test the session-wide broadcast. + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + { + ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + }); + + var serverOptions = ServiceProvider.GetRequiredService>().Value; + var serverTools = serverOptions.ToolCollection; + Assert.NotNull(serverTools); + + int notificationCount = 0; + var firstNotification = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using (client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification, (notification, cancellationToken) => + { + if (Interlocked.Increment(ref notificationCount) == 1) + { + firstNotification.TrySetResult(true); + } + return default; + })) + { + using (serverTools.DeferChangedEvents()) + { + serverTools.Add(McpServerTool.Create([McpServerTool(Name = "BatchTool1")] () => "1")); + serverTools.Add(McpServerTool.Create([McpServerTool(Name = "BatchTool2")] () => "2")); + serverTools.Add(McpServerTool.Create([McpServerTool(Name = "BatchTool3")] () => "3")); + } + + await firstNotification.Task.WaitAsync(TestContext.Current.CancellationToken); + + // Do a round-trip so that any second (erroneous) notification has time to arrive. + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains(tools, t => t.Name == "BatchTool1"); + Assert.Contains(tools, t => t.Name == "BatchTool2"); + Assert.Contains(tools, t => t.Name == "BatchTool3"); + + Assert.Equal(1, notificationCount); + } + } + [Fact] public async Task Can_Call_Registered_Tool() { diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs new file mode 100644 index 000000000..7acac660e --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerPrimitiveCollectionTests.cs @@ -0,0 +1,595 @@ +using Microsoft.Extensions.AI; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Server; + +public class McpServerPrimitiveCollectionTests +{ + private static McpServerTool CreateTool(string name) => + McpServerTool.Create(() => name, new() { Name = name }); + + private static McpServerPrompt CreatePrompt(string name) => + McpServerPrompt.Create(() => new ChatMessage(ChatRole.User, name), new() { Name = name }); + + // ------------------------------------------------------------------------- + // Changed event without DeferChangedEvents + // ------------------------------------------------------------------------- + + [Fact] + public void TryAdd_NewTool_ReturnsTrue_FiresChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + bool added = collection.TryAdd(CreateTool("tool1")); + + Assert.True(added); + Assert.Equal(1, changeCount); + } + + [Fact] + public void TryAdd_DuplicateName_ReturnsFalse_DoesNotFireChanged() + { + var collection = new McpServerPrimitiveCollection(); + collection.TryAdd(CreateTool("tool1")); + + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + bool added = collection.TryAdd(CreateTool("tool1")); + + Assert.False(added); + Assert.Equal(0, changeCount); + } + + [Fact] + public void TryAdd_SameTool_TwiceInSequence_FiresOnlyOnFirst() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + bool first = collection.TryAdd(CreateTool("tool1")); + bool second = collection.TryAdd(CreateTool("tool1")); + + Assert.True(first); + Assert.False(second); + Assert.Equal(1, changeCount); + } + + [Fact] + public void Remove_ExistingTool_ReturnsTrue_FiresChanged() + { + var tool = CreateTool("tool1"); + var collection = new McpServerPrimitiveCollection(); + collection.TryAdd(tool); + + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + bool removed = collection.Remove(tool); + + Assert.True(removed); + Assert.Equal(1, changeCount); + } + + [Fact] + public void Remove_NonExistentTool_ReturnsFalse_DoesNotFireChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + bool removed = collection.Remove(CreateTool("tool1")); + + Assert.False(removed); + Assert.Equal(0, changeCount); + } + + [Fact] + public void Clear_NonEmptyCollection_FiresChanged() + { + var collection = new McpServerPrimitiveCollection(); + collection.TryAdd(CreateTool("tool1")); + collection.TryAdd(CreateTool("tool2")); + + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + collection.Clear(); + + Assert.Equal(1, changeCount); + Assert.Empty(collection); + } + + [Fact] + public void Clear_EmptyCollection_FiresChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + collection.Clear(); + + Assert.Equal(1, changeCount); + } + + // ------------------------------------------------------------------------- + // DeferChangedEvents -- basic deferral behavior + // ------------------------------------------------------------------------- + + [Fact] + public void DeferChangedEvents_NoMutation_DoesNotFireChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + // no mutations + } + + Assert.Equal(0, changeCount); + } + + [Fact] + public void DeferChangedEvents_SingleMutation_FiresOneChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool1")); + Assert.Equal(0, changeCount); // not fired yet + } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_MultipleMutations_FiresOneChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool1")); + collection.TryAdd(CreateTool("tool2")); + collection.TryAdd(CreateTool("tool3")); + Assert.Equal(0, changeCount); + } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_MixedAddAndRemove_FiresOneChanged() + { + var tool = CreateTool("tool1"); + var collection = new McpServerPrimitiveCollection(); + collection.TryAdd(tool); + + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool2")); + collection.Remove(tool); + Assert.Equal(0, changeCount); + } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_AddThenRemoveSameTool_FiresOneChanged() + { + // Net effect is no change in contents, but a Changed notification still fires + // because mutations occurred during the scope. + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + var tool = CreateTool("tool1"); + collection.TryAdd(tool); + collection.Remove(tool); + Assert.Equal(0, changeCount); + } + + Assert.Equal(1, changeCount); + Assert.Empty(collection); + } + + [Fact] + public void DeferChangedEvents_DuplicateTryAdd_OnlySuccessfulMutationMarksChange() + { + // The first TryAdd succeeds (mutation), the second fails (no mutation). + // Exactly one Changed fires on dispose. + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool1")); // succeeds + collection.TryAdd(CreateTool("tool1")); // fails -- duplicate name + Assert.Equal(0, changeCount); + } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_OnlyFailedTryAdds_DoesNotFireChanged() + { + var tool = CreateTool("tool1"); + var collection = new McpServerPrimitiveCollection(); + collection.TryAdd(tool); + + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool1")); // fails -- already present + Assert.Equal(0, changeCount); + } + + Assert.Equal(0, changeCount); + } + + [Fact] + public void DeferChangedEvents_WithClear_FiresOneChanged() + { + var collection = new McpServerPrimitiveCollection(); + collection.TryAdd(CreateTool("tool1")); + collection.TryAdd(CreateTool("tool2")); + + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.Clear(); + Assert.Equal(0, changeCount); + } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_Nested_FiresOnceWhenAllScopesDisposed() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool1")); + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool2")); + Assert.Equal(0, changeCount); + } + + Assert.Equal(0, changeCount); // inner scope disposed, but outer still active + collection.TryAdd(CreateTool("tool3")); + } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_OutOfOrderDisposal_FiresOnceWhenAllScopesDisposed() + { + // Scopes created in order 1, 2 but disposed in reverse order 2, 1. + // Changed should NOT fire when scope 2 is disposed (scope 1 still active). + // Changed SHOULD fire when scope 1 is disposed (last active scope gone). + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + var scope1 = collection.DeferChangedEvents(); + var scope2 = collection.DeferChangedEvents(); + collection.TryAdd(CreateTool("tool1")); + + Assert.Equal(0, changeCount); + scope2.Dispose(); // out-of-order dispose; scope1 still active + Assert.Equal(0, changeCount); + + scope1.Dispose(); // last scope disposed; Changed fires now + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_DoubleDisposeSingleScope_DoesNotDecrementCountTwice() + { + // Double-disposing scope1 must not decrement _activeDeferralScopes more than once, + // which would cause Changed to fire while scope2 is still active. + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + var scope1 = collection.DeferChangedEvents(); + var scope2 = collection.DeferChangedEvents(); + collection.TryAdd(CreateTool("tool1")); + + scope1.Dispose(); + Assert.Equal(0, changeCount); // scope2 still active + + scope1.Dispose(); // second dispose of scope1 -- must be a no-op + Assert.Equal(0, changeCount); // scope2 is still active; Changed must NOT fire yet + + scope2.Dispose(); // now all scopes are disposed; Changed fires + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_OutOfOrderDisposalNoMutation_DoesNotFireChanged() + { + // Same out-of-order pattern but with no mutations -- verifies no spurious Changed. + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + var scope1 = collection.DeferChangedEvents(); + var scope2 = collection.DeferChangedEvents(); + + scope2.Dispose(); + scope1.Dispose(); + + Assert.Equal(0, changeCount); + } + + [Fact] + public void DeferChangedEvents_AfterScope_ResumesImmediateNotifications() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool1")); + } + + Assert.Equal(1, changeCount); + + // After the scope, each mutation fires immediately + collection.TryAdd(CreateTool("tool2")); + Assert.Equal(2, changeCount); + + collection.TryAdd(CreateTool("tool3")); + Assert.Equal(3, changeCount); + } + + [Fact] + public void DeferChangedEvents_DisposeIdempotent_DoesNotFireTwice() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + var scope = collection.DeferChangedEvents(); + collection.TryAdd(CreateTool("tool1")); + + scope.Dispose(); + Assert.Equal(1, changeCount); + + scope.Dispose(); // second dispose should be a no-op + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_ScopeWithNoHandlers_DoesNotThrow() + { + var collection = new McpServerPrimitiveCollection(); + // no Changed handler registered + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool1")); + } + + Assert.Single(collection); + } + + [Fact] + public void WithoutDeferChangedEvents_EachMutationFiresImmediately() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + collection.TryAdd(CreateTool("tool1")); + Assert.Equal(1, changeCount); + + collection.TryAdd(CreateTool("tool2")); + Assert.Equal(2, changeCount); + + collection.TryAdd(CreateTool("tool3")); + Assert.Equal(3, changeCount); + } + + // ------------------------------------------------------------------------- + // DeferChangedEvents -- concurrency + // ------------------------------------------------------------------------- + + [Fact] + public async Task DeferChangedEvents_ConcurrentMutations_FiresExactlyOneChanged() + { + const int threadCount = 10; + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => Interlocked.Increment(ref changeCount); + + using (collection.DeferChangedEvents()) + { + await Task.WhenAll(Enumerable.Range(0, threadCount).Select(i => + Task.Run(() => collection.TryAdd(CreateTool($"tool{i}")), TestContext.Current.CancellationToken))); + } + + Assert.Equal(1, changeCount); + Assert.Equal(threadCount, collection.Count); + } + + [Fact] + public async Task DeferChangedEvents_MutationRacingWithDispose_NotificationNotLost() + { + // Run many iterations to reliably exercise the race between a mutation + // and disposal of the outermost scope. With the lock-free implementation + // the race could cause the notification to be lost; the lock-based + // implementation must always fire exactly one notification. + for (int iteration = 0; iteration < 200; iteration++) + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => Interlocked.Increment(ref changeCount); + + var scope = collection.DeferChangedEvents(); + + // Run the mutation and the dispose concurrently. + var addTask = Task.Run(() => collection.TryAdd(CreateTool("tool1")), TestContext.Current.CancellationToken); + var disposeTask = Task.Run(() => scope.Dispose(), TestContext.Current.CancellationToken); + + await Task.WhenAll(addTask, disposeTask); + + // Regardless of ordering: exactly one notification must have fired. + // - If TryAdd runs before Dispose: the mutation marks _pendingChange; + // Dispose sees depth -> 0 with a pending change and fires. + // - If Dispose runs before TryAdd: depth is already 0 when TryAdd + // calls RaiseChanged, so it fires immediately. + // The lock prevents the third (buggy) interleaving where Dispose + // sees no pending change and TryAdd sees depth > 0, dropping the event. + Assert.Equal(1, changeCount); + } + } + + // ------------------------------------------------------------------------- + // DeferChangedEvents -- derived-type coalescing + // ------------------------------------------------------------------------- + + private sealed class TrackingCollection : McpServerPrimitiveCollection + { + public void RaiseChangedDirectly() => RaiseChanged(); + } + + [Fact] + public void DeferChangedEvents_DerivedTypeCallsRaiseChanged_Coalesces() + { + // Verify that derived types calling RaiseChanged() directly (the path + // McpServerResourceCollection and other subclasses rely on) are gated + // by the same deferral check. + var collection = new TrackingCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.RaiseChangedDirectly(); + collection.RaiseChangedDirectly(); + Assert.Equal(0, changeCount); + } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_DerivedTypeRaisesChanged_OutsideScope_FiresImmediately() + { + var collection = new TrackingCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + collection.RaiseChangedDirectly(); + Assert.Equal(1, changeCount); + + collection.RaiseChangedDirectly(); + Assert.Equal(2, changeCount); + } + + // ------------------------------------------------------------------------- + // DeferChangedEvents -- exception safety + // ------------------------------------------------------------------------- + + [Fact] + public void DeferChangedEvents_ExceptionDuringScope_StillFiresChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + try + { + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool1")); + throw new InvalidOperationException("test"); + } + } + catch (InvalidOperationException) { } + + Assert.Equal(1, changeCount); + } + + [Fact] + public void DeferChangedEvents_ExceptionDuringScope_ResumesImmediateNotificationsAfterward() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + try + { + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreateTool("tool1")); + throw new InvalidOperationException("test"); + } + } + catch (InvalidOperationException) { } + + Assert.Equal(1, changeCount); + + // Deferral must be fully reset: mutations outside the scope fire immediately. + collection.TryAdd(CreateTool("tool2")); + Assert.Equal(2, changeCount); + + collection.TryAdd(CreateTool("tool3")); + Assert.Equal(3, changeCount); + } + + // ------------------------------------------------------------------------- + // DeferChangedEvents -- prompt collection coverage + // ------------------------------------------------------------------------- + + [Fact] + public void DeferChangedEvents_PromptCollection_MultipleMutations_FiresOneChanged() + { + var collection = new McpServerPrimitiveCollection(); + int changeCount = 0; + collection.Changed += (_, _) => changeCount++; + + using (collection.DeferChangedEvents()) + { + collection.TryAdd(CreatePrompt("prompt1")); + collection.TryAdd(CreatePrompt("prompt2")); + collection.TryAdd(CreatePrompt("prompt3")); + Assert.Equal(0, changeCount); + } + + Assert.Equal(1, changeCount); + Assert.Equal(3, collection.Count); + } +} From 9326c8738c35df492d946abe74303dfc35b942fa Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Wed, 8 Jul 2026 13:41:16 -0700 Subject: [PATCH 06/16] Harden Tasks seams in PR #1693 review - Reject custom RequestHandlers that collide with built-in or duplicate methods - Throw when an outgoing-request interceptor returns no result for sampling/roots - Guard the Tasks background execution against unobserved store exceptions and log failures - Add collision-guard tests for custom request handlers --- .../Server/McpServer.Methods.cs | 13 +- .../Server/McpServerImpl.cs | 19 ++- .../Server/McpTasksBuilderExtensions.cs | 130 +++++++++++------- .../CustomRequestHandlerCollisionTests.cs | 79 +++++++++++ 4 files changed, 191 insertions(+), 50 deletions(-) create mode 100644 tests/ModelContextProtocol.Tests/Server/CustomRequestHandlerCollisionTests.cs diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs index d6cb83c70..6e3c94f6a 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs @@ -622,7 +622,18 @@ private async ValueTask SendRequestViaInterceptorAsync 0 } customHandlers) -#pragma warning restore MCPEXP002 { return; } foreach (var entry in customHandlers) { + if (string.IsNullOrEmpty(entry.Method)) + { + throw new InvalidOperationException( + $"A custom request handler registered through {nameof(McpServerOptions)}.{nameof(McpServerOptions.RequestHandlers)} has a null or empty {nameof(McpServerRequestHandler.Method)}."); + } + + // Custom handlers are registered after all built-in handlers, so a method already present + // belongs to a built-in method (e.g. initialize, tools/call) or an earlier custom handler. + // Silently overwriting it would bypass the built-in handler's filters and protocol gating, + // so reject the collision instead. + if (_requestHandlers.ContainsKey(entry.Method)) + { + throw new InvalidOperationException( + $"A custom request handler registered through {nameof(McpServerOptions)}.{nameof(McpServerOptions.RequestHandlers)} " + + $"uses the method '{entry.Method}', which is already handled by the server. Custom handlers cannot replace built-in methods or other custom handlers."); + } + SetRawHandler(entry.Method, entry.Handler); } +#pragma warning restore MCPEXP002 } private void SetRawHandler(string method, Func> handler) diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs index ec01e86bd..599b426d8 100644 --- a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs @@ -1,4 +1,6 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ModelContextProtocol; using ModelContextProtocol.Protocol; @@ -30,13 +32,18 @@ public static IMcpServerBuilder WithTasks(this IMcpServerBuilder builder, IMcpTa if (store is null) throw new ArgumentNullException(nameof(store)); #endif - builder.Services.AddSingleton>(new McpTasksPostConfigureOptions(store)); + // Resolve ILoggerFactory from the provider (rather than requiring the caller to pass one) so the + // background task body has somewhere to report failures. It is optional: if no logging is + // registered, the options fall back to NullLoggerFactory. + builder.Services.AddSingleton>( + sp => new McpTasksPostConfigureOptions(store, sp.GetService())); return builder; } - private sealed class McpTasksPostConfigureOptions(IMcpTaskStore store) : IPostConfigureOptions + private sealed class McpTasksPostConfigureOptions(IMcpTaskStore store, ILoggerFactory? loggerFactory) : IPostConfigureOptions { private readonly IMcpTaskStore _store = store; + private readonly ILogger _logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger(); private readonly ConcurrentDictionary _cancellationSources = new(StringComparer.Ordinal); public void PostConfigure(string? name, McpServerOptions options) @@ -74,71 +81,98 @@ public void PostConfigure(string? name, McpServerOptions options) _ = Task.Run(async () => { - using (McpTasksServerExtensions.CreateMcpTaskScope(request.Server, taskId, _store)) + try { - try + using (McpTasksServerExtensions.CreateMcpTaskScope(request.Server, taskId, _store)) { - var augmented = await next(request, taskCancellationToken).ConfigureAwait(false); + try + { + var augmented = await next(request, taskCancellationToken).ConfigureAwait(false); - if (augmented.IsAlternate) + if (augmented.IsAlternate) + { + var error = new JsonRpcErrorDetail + { + Code = (int)McpErrorCode.InternalError, + Message = $"{nameof(IMcpTaskStore)} is configured and the {nameof(McpServerHandlers.CallToolWithAlternateHandler)} returned IsAlternate = true. Use only one mechanism.", + }; + var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); + return; + } + + var resultJson = JsonSerializer.SerializeToElement(augmented.Result!, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + await _store.SetCompletedAsync(taskId, resultJson).ConfigureAwait(false); + } + catch (OperationCanceledException) when (taskCancellationToken.IsCancellationRequested) + { + await _store.SetCancelledAsync(taskId, CancellationToken.None).ConfigureAwait(false); + } + catch (InputRequiredException) { var error = new JsonRpcErrorDetail { - Code = (int)McpErrorCode.InternalError, - Message = $"{nameof(IMcpTaskStore)} is configured and the {nameof(McpServerHandlers.CallToolWithAlternateHandler)} returned IsAlternate = true. Use only one mechanism.", + Code = (int)McpErrorCode.InvalidRequest, + Message = "MRTR and tasks cannot be composed via [McpServerTool] yet.", }; var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo()); await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); - return; } - - var resultJson = JsonSerializer.SerializeToElement(augmented.Result!, McpJsonUtilities.DefaultOptions.GetTypeInfo()); - await _store.SetCompletedAsync(taskId, resultJson).ConfigureAwait(false); - } - catch (OperationCanceledException) when (taskCancellationToken.IsCancellationRequested) - { - await _store.SetCancelledAsync(taskId, CancellationToken.None).ConfigureAwait(false); - } - catch (InputRequiredException) - { - var error = new JsonRpcErrorDetail + catch (McpProtocolException mcpEx) { - Code = (int)McpErrorCode.InvalidRequest, - Message = "MRTR and tasks cannot be composed via [McpServerTool] yet.", - }; - var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo()); - await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); + // SEP-2663 §186: protocol exceptions store as failed with JSON-RPC error shape. + var error = new JsonRpcErrorDetail { Code = (int)mcpEx.ErrorCode, Message = mcpEx.Message }; + var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); + } + catch (Exception ex) + { + // Non-protocol exceptions are wrapped as CallToolResult { IsError = true }, + // matching Core's BuildInitialAlternateToolFilter behavior. + var errorResult = new CallToolResult + { + IsError = true, + Content = [new TextContentBlock + { + Text = ex is McpException + ? $"An error occurred invoking '{request.Params?.Name}': {ex.Message}" + : $"An error occurred invoking '{request.Params?.Name}'.", + }], + }; + var resultJson = JsonSerializer.SerializeToElement(errorResult, McpJsonUtilities.DefaultOptions.GetTypeInfo()); + await _store.SetCompletedAsync(taskId, resultJson).ConfigureAwait(false); + } + finally + { + if (_cancellationSources.TryRemove(taskId, out var registeredCts)) + { + registeredCts.Dispose(); + } + } } - catch (McpProtocolException mcpEx) + } + catch (Exception outer) + { + // The inner handlers above record every expected outcome. Reaching here means a + // store operation inside one of those handlers (or the task scope) threw, most + // likely from a custom IMcpTaskStore. Record the failure best-effort and never let + // it surface as an unobserved task exception. + _logger.LogError(outer, "Background execution of task '{TaskId}' terminated unexpectedly while recording its result.", taskId); + + try { - // SEP-2663 §186: protocol exceptions store as failed with JSON-RPC error shape. - var error = new JsonRpcErrorDetail { Code = (int)mcpEx.ErrorCode, Message = mcpEx.Message }; + var error = new JsonRpcErrorDetail { Code = (int)McpErrorCode.InternalError, Message = outer.Message }; var errorJson = JsonSerializer.SerializeToElement(error, McpJsonUtilities.DefaultOptions.GetTypeInfo()); await _store.SetFailedAsync(taskId, errorJson).ConfigureAwait(false); } - catch (Exception ex) + catch (Exception storeEx) { - // Non-protocol exceptions are wrapped as CallToolResult { IsError = true }, - // matching Core's BuildInitialAlternateToolFilter behavior. - var errorResult = new CallToolResult - { - IsError = true, - Content = [new TextContentBlock - { - Text = ex is McpException - ? $"An error occurred invoking '{request.Params?.Name}': {ex.Message}" - : $"An error occurred invoking '{request.Params?.Name}'.", - }], - }; - var resultJson = JsonSerializer.SerializeToElement(errorResult, McpJsonUtilities.DefaultOptions.GetTypeInfo()); - await _store.SetCompletedAsync(taskId, resultJson).ConfigureAwait(false); + _logger.LogError(storeEx, "Failed to record the failure of background task '{TaskId}'.", taskId); } - finally + + if (_cancellationSources.TryRemove(taskId, out var leftoverCts)) { - if (_cancellationSources.TryRemove(taskId, out var registeredCts)) - { - registeredCts.Dispose(); - } + leftoverCts.Dispose(); } } }, CancellationToken.None); diff --git a/tests/ModelContextProtocol.Tests/Server/CustomRequestHandlerCollisionTests.cs b/tests/ModelContextProtocol.Tests/Server/CustomRequestHandlerCollisionTests.cs new file mode 100644 index 000000000..424537461 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/CustomRequestHandlerCollisionTests.cs @@ -0,0 +1,79 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Verifies that custom request handlers registered through +/// cannot silently replace a built-in method +/// or another custom handler. +/// +public class CustomRequestHandlerCollisionTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper) +{ +#pragma warning disable MCPEXP002 + private static McpServerRequestHandler CreateHandler(string method) => new() + { + Method = method, + Handler = (request, cancellationToken) => new ValueTask((JsonNode?)null), + }; + + [Fact] + public async Task CustomHandler_CollidingWithBuiltInMethod_Throws() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions + { + Capabilities = new() { Tools = new() }, + RequestHandlers = [CreateHandler("tools/call")], + }; + + var ex = Assert.Throws( + () => McpServer.Create(transport, options, LoggerFactory)); + + Assert.Contains("tools/call", ex.Message); + } + + [Fact] + public async Task CustomHandler_CollidingWithInitialize_Throws() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions + { + RequestHandlers = [CreateHandler("initialize")], + }; + + Assert.Throws( + () => McpServer.Create(transport, options, LoggerFactory)); + } + + [Fact] + public async Task CustomHandler_DuplicateCustomMethod_Throws() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions + { + RequestHandlers = [CreateHandler("custom/method"), CreateHandler("custom/method")], + }; + + var ex = Assert.Throws( + () => McpServer.Create(transport, options, LoggerFactory)); + + Assert.Contains("custom/method", ex.Message); + } + + [Fact] + public async Task CustomHandler_UniqueMethod_Succeeds() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions + { + RequestHandlers = [CreateHandler("custom/method")], + }; + + await using var server = McpServer.Create(transport, options, LoggerFactory); + Assert.NotNull(server); + } +#pragma warning restore MCPEXP002 +} From d3b7fa11425fe48c692fcd6b0b57d32ca43d07aa Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Wed, 8 Jul 2026 14:15:06 -0700 Subject: [PATCH 07/16] Honor task TTL in InMemoryMcpTaskStore Discard tasks once their advertised time-to-live elapses: expired tasks are removed on access and a throttled opportunistic sweep reclaims expired tasks that are never polled again. A null or non-positive TimeToLive keeps tasks for the process lifetime, so the default behavior is unchanged. --- .../Server/InMemoryMcpTaskStore.cs | 64 +++++++++++++++++-- .../Server/InMemoryMcpTaskStoreTests.cs | 53 +++++++++++++++ 2 files changed, 112 insertions(+), 5 deletions(-) diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/InMemoryMcpTaskStore.cs index 1b8b61246..d1014c110 100644 --- a/src/ModelContextProtocol.Extensions.Tasks/Server/InMemoryMcpTaskStore.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/InMemoryMcpTaskStore.cs @@ -15,13 +15,21 @@ namespace ModelContextProtocol.Extensions.Tasks; /// Tasks are not persisted across process restarts. /// /// -/// For production scenarios requiring durability, session isolation, or TTL-based cleanup, -/// implement a custom . +/// Tasks created with a are discarded once their time-to-live +/// elapses (as permitted by SEP-2663): an expired task is removed on access, and an opportunistic +/// throttled sweep reclaims expired tasks that are never polled again. Tasks created without a +/// time-to-live are retained until the process exits. +/// +/// +/// For production scenarios requiring durability, session isolation, or more advanced retention +/// policies, implement a custom . /// /// public class InMemoryMcpTaskStore : IMcpTaskStore { private readonly ConcurrentDictionary _tasks = new(); + private static readonly long s_sweepIntervalTicks = TimeSpan.FromSeconds(30).Ticks; + private long _lastSweepTicks = DateTimeOffset.UtcNow.UtcTicks; /// /// Gets or sets the default poll interval in milliseconds for new tasks. @@ -32,13 +40,19 @@ public class InMemoryMcpTaskStore : IMcpTaskStore /// /// Gets or sets the default time-to-live for new tasks, or for unlimited. /// + /// + /// When set to a positive value, tasks are discarded once this duration elapses from their + /// creation. A or non-positive value keeps tasks until the process exits. + /// public TimeSpan? DefaultTimeToLive { get; set; } /// public Task CreateTaskAsync(CancellationToken cancellationToken = default) { - var taskId = Guid.NewGuid().ToString("N"); var now = DateTimeOffset.UtcNow; + SweepExpired(now); + + var taskId = Guid.NewGuid().ToString("N"); var info = new McpTaskInfo(taskId, McpTaskStatus.Working, now, now, DefaultTimeToLive, DefaultPollIntervalMs); _tasks[taskId] = info; @@ -49,8 +63,19 @@ public Task CreateTaskAsync(CancellationToken cancellationToken = d /// public Task GetTaskAsync(string taskId, CancellationToken cancellationToken = default) { - _tasks.TryGetValue(taskId, out var info); - return Task.FromResult(info); + var now = DateTimeOffset.UtcNow; + if (_tasks.TryGetValue(taskId, out var info)) + { + if (IsExpired(info, now)) + { + _tasks.TryRemove(taskId, out _); + return Task.FromResult(null); + } + + return Task.FromResult(info); + } + + return Task.FromResult(null); } /// @@ -198,6 +223,35 @@ public Task SetInputRequestsAsync( private static bool IsTerminal(McpTaskStatus status) => status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled; + private static bool IsExpired(McpTaskInfo info, DateTimeOffset now) => + info.TimeToLive is { } ttl && ttl > TimeSpan.Zero && now - info.CreatedAt >= ttl; + + private void SweepExpired(DateTimeOffset now) + { + long last = Interlocked.Read(ref _lastSweepTicks); + if (now.UtcTicks - last < s_sweepIntervalTicks) + { + return; + } + + // Ensure only one caller runs the sweep per interval; concurrent callers skip it. + if (Interlocked.CompareExchange(ref _lastSweepTicks, now.UtcTicks, last) != last) + { + return; + } + + foreach (var kvp in _tasks) + { + if (IsExpired(kvp.Value, now)) + { + // TaskId values are unique GUIDs that are never reused, and CreatedAt/TimeToLive + // are immutable after creation, so an entry judged expired here stays expired. + // Removing by key is therefore safe even if the value was concurrently updated. + _tasks.TryRemove(kvp.Key, out _); + } + } + } + private void Update(string taskId, Func transform) { SpinWait spin = default; diff --git a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs index 5015a5f30..236740f61 100644 --- a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs @@ -88,6 +88,59 @@ public async Task GetTaskAsync_ReturnsNullForUnknownId() Assert.Null(result); } + [Fact] + public async Task GetTaskAsync_WithinTimeToLive_ReturnsTask() + { + var store = new InMemoryMcpTaskStore { DefaultTimeToLive = TimeSpan.FromMinutes(10) }; + var created = await store.CreateTaskAsync(CT); + + var result = await store.GetTaskAsync(created.TaskId, CT); + + Assert.NotNull(result); + Assert.Equal(created.TaskId, result.TaskId); + } + + [Fact] + public async Task GetTaskAsync_AfterTimeToLiveElapsed_ReturnsNull() + { + var store = new InMemoryMcpTaskStore { DefaultTimeToLive = TimeSpan.FromMilliseconds(100) }; + var created = await store.CreateTaskAsync(CT); + + await Task.Delay(TimeSpan.FromMilliseconds(500), CT); + + var result = await store.GetTaskAsync(created.TaskId, CT); + + Assert.Null(result); + } + + [Fact] + public async Task GetTaskAsync_WithoutTimeToLive_DoesNotExpire() + { + var store = new InMemoryMcpTaskStore(); + var created = await store.CreateTaskAsync(CT); + + await Task.Delay(TimeSpan.FromMilliseconds(200), CT); + + var result = await store.GetTaskAsync(created.TaskId, CT); + + Assert.NotNull(result); + Assert.Equal(created.TaskId, result.TaskId); + } + + [Fact] + public async Task GetTaskAsync_WithZeroTimeToLive_DoesNotExpire() + { + var store = new InMemoryMcpTaskStore { DefaultTimeToLive = TimeSpan.Zero }; + var created = await store.CreateTaskAsync(CT); + + await Task.Delay(TimeSpan.FromMilliseconds(200), CT); + + var result = await store.GetTaskAsync(created.TaskId, CT); + + Assert.NotNull(result); + Assert.Equal(created.TaskId, result.TaskId); + } + [Fact] public async Task SetCompletedAsync_TransitionsToCompleted() { From 2e941c5a2dcd900f17532ca2670d030efdb6f934 Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Wed, 8 Jul 2026 14:30:44 -0700 Subject: [PATCH 08/16] Clarify error when mixing call-tool filter styles Improve the InvalidOperationException thrown when CallToolFilters and CallToolWithAlternateFilters are both configured, naming the common indirect cause (combining AddAuthorizationFilters() with WithTasks()) and the current limitation. Add filter property doc notes and tests. --- .../Server/McpRequestFilters.cs | 8 ++- .../Server/McpServerImpl.cs | 7 ++- .../Server/CallToolFilterMixingTests.cs | 58 +++++++++++++++++++ 3 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 tests/ModelContextProtocol.Tests/Server/CallToolFilterMixingTests.cs diff --git a/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs b/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs index c31125af7..a82baf598 100644 --- a/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs +++ b/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs @@ -43,7 +43,9 @@ public IList> ListTool /// /// /// Cannot be used together with . If both are non-empty at configuration time, - /// an will be thrown. + /// an will be thrown. This can happen indirectly when combining features that + /// register different tool-call filter styles, such as authorization filters (which use this collection) and the + /// tasks extension (which uses ). /// /// public IList> CallToolFilters @@ -68,7 +70,9 @@ public IList> CallToolFi /// /// /// Cannot be used together with . If both are non-empty at configuration time, - /// an will be thrown. + /// an will be thrown. This can happen indirectly when combining features that + /// register different tool-call filter styles, such as the tasks extension (which uses this collection) and + /// authorization filters (which use ). /// /// public IList>> CallToolWithAlternateFilters diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 73e3b5acd..8ab83ca29 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -1083,7 +1083,12 @@ private void ConfigureTools(McpServerOptions options) { throw new InvalidOperationException( $"Cannot mix non-alternate ({nameof(McpServerHandlers.CallToolHandler)}/{nameof(McpRequestFilters.CallToolFilters)}) " + - $"with alternate-based ({nameof(McpServerHandlers.CallToolWithAlternateHandler)}/{nameof(McpRequestFilters.CallToolWithAlternateFilters)}). Use one style or the other."); + $"with alternate-based ({nameof(McpServerHandlers.CallToolWithAlternateHandler)}/{nameof(McpRequestFilters.CallToolWithAlternateFilters)}) tool-call filters or handlers. " + + $"These two styles cannot currently be composed on the same server. " + + $"This most commonly happens when combining features that register different tool-call filter styles, " + + $"for example AddAuthorizationFilters() (which registers a {nameof(McpRequestFilters.CallToolFilters)} filter) together with " + + $"WithTasks() (which registers a {nameof(McpRequestFilters.CallToolWithAlternateFilters)} filter). " + + $"Configure only one style, or avoid combining features that require different styles."); } // Handle tools provided via DI by augmenting the list handler. diff --git a/tests/ModelContextProtocol.Tests/Server/CallToolFilterMixingTests.cs b/tests/ModelContextProtocol.Tests/Server/CallToolFilterMixingTests.cs new file mode 100644 index 000000000..259896bcb --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/CallToolFilterMixingTests.cs @@ -0,0 +1,58 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Verifies that combining the non-alternate with the +/// alternate fails at configuration time +/// with an actionable message. +/// +public class CallToolFilterMixingTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper) +{ + private static McpRequestFilter PassThroughCallToolFilter => + next => next; + + private static McpRequestFilter> PassThroughAlternateFilter => + next => next; + + [Fact] + public async Task MixingCallToolFilters_WithAlternateFilters_ThrowsActionableError() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions { Capabilities = new() { Tools = new() } }; + options.Filters.Request.CallToolFilters.Add(PassThroughCallToolFilter); + options.Filters.Request.CallToolWithAlternateFilters.Add(PassThroughAlternateFilter); + + var ex = Assert.Throws( + () => McpServer.Create(transport, options, LoggerFactory)); + + Assert.Contains(nameof(McpRequestFilters.CallToolFilters), ex.Message); + Assert.Contains(nameof(McpRequestFilters.CallToolWithAlternateFilters), ex.Message); + Assert.Contains("AddAuthorizationFilters", ex.Message); + Assert.Contains("WithTasks", ex.Message); + } + + [Fact] + public async Task CallToolFiltersAlone_Succeeds() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions { Capabilities = new() { Tools = new() } }; + options.Filters.Request.CallToolFilters.Add(PassThroughCallToolFilter); + + await using var server = McpServer.Create(transport, options, LoggerFactory); + Assert.NotNull(server); + } + + [Fact] + public async Task AlternateFiltersAlone_Succeeds() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions { Capabilities = new() { Tools = new() } }; + options.Filters.Request.CallToolWithAlternateFilters.Add(PassThroughAlternateFilter); + + await using var server = McpServer.Create(transport, options, LoggerFactory); + Assert.NotNull(server); + } +} From 973c25332750248e8c970cbcfce4c82a99cfecb3 Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Wed, 8 Jul 2026 14:55:58 -0700 Subject: [PATCH 09/16] Update Core CompatibilitySuppressions.xml for removed Tasks APIs The Tasks feature moved from ModelContextProtocol.Core to the new ModelContextProtocol.Extensions.Tasks package, removing several public APIs from Core. Regenerated the baseline suppression entries so package validation against the 1.3.0 baseline passes. --- .../CompatibilitySuppressions.xml | 436 ++++++++++-------- 1 file changed, 232 insertions(+), 204 deletions(-) diff --git a/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml b/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml index e686b454f..b2421e854 100644 --- a/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml +++ b/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml @@ -57,6 +57,13 @@ lib/net10.0/ModelContextProtocol.Core.dll true + + CP0001 + T:ModelContextProtocol.Protocol.CreateTaskResult + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + CP0001 T:ModelContextProtocol.Protocol.ElicitationMcpTasksCapability @@ -71,6 +78,20 @@ lib/net10.0/ModelContextProtocol.Core.dll true + + CP0001 + T:ModelContextProtocol.Protocol.GetTaskRequestParams + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.GetTaskResult + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + CP0001 T:ModelContextProtocol.Protocol.ListMcpTasksCapability @@ -113,6 +134,13 @@ lib/net10.0/ModelContextProtocol.Core.dll true + + CP0001 + T:ModelContextProtocol.Protocol.McpTaskStatus + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + CP0001 T:ModelContextProtocol.Protocol.McpTaskStatusNotificationParams @@ -211,6 +239,13 @@ lib/net8.0/ModelContextProtocol.Core.dll true + + CP0001 + T:ModelContextProtocol.Protocol.CreateTaskResult + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + CP0001 T:ModelContextProtocol.Protocol.ElicitationMcpTasksCapability @@ -225,6 +260,20 @@ lib/net8.0/ModelContextProtocol.Core.dll true + + CP0001 + T:ModelContextProtocol.Protocol.GetTaskRequestParams + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.GetTaskResult + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + CP0001 T:ModelContextProtocol.Protocol.ListMcpTasksCapability @@ -267,6 +316,13 @@ lib/net8.0/ModelContextProtocol.Core.dll true + + CP0001 + T:ModelContextProtocol.Protocol.McpTaskStatus + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + CP0001 T:ModelContextProtocol.Protocol.McpTaskStatusNotificationParams @@ -365,6 +421,13 @@ lib/net9.0/ModelContextProtocol.Core.dll true + + CP0001 + T:ModelContextProtocol.Protocol.CreateTaskResult + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + CP0001 T:ModelContextProtocol.Protocol.ElicitationMcpTasksCapability @@ -379,6 +442,20 @@ lib/net9.0/ModelContextProtocol.Core.dll true + + CP0001 + T:ModelContextProtocol.Protocol.GetTaskRequestParams + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.GetTaskResult + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + CP0001 T:ModelContextProtocol.Protocol.ListMcpTasksCapability @@ -421,6 +498,13 @@ lib/net9.0/ModelContextProtocol.Core.dll true + + CP0001 + T:ModelContextProtocol.Protocol.McpTaskStatus + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + CP0001 T:ModelContextProtocol.Protocol.McpTaskStatusNotificationParams @@ -519,6 +603,13 @@ lib/netstandard2.0/ModelContextProtocol.Core.dll true + + CP0001 + T:ModelContextProtocol.Protocol.CreateTaskResult + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + CP0001 T:ModelContextProtocol.Protocol.ElicitationMcpTasksCapability @@ -533,6 +624,20 @@ lib/netstandard2.0/ModelContextProtocol.Core.dll true + + CP0001 + T:ModelContextProtocol.Protocol.GetTaskRequestParams + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0001 + T:ModelContextProtocol.Protocol.GetTaskResult + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + CP0001 T:ModelContextProtocol.Protocol.ListMcpTasksCapability @@ -575,6 +680,13 @@ lib/netstandard2.0/ModelContextProtocol.Core.dll true + + CP0001 + T:ModelContextProtocol.Protocol.McpTaskStatus + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + CP0001 T:ModelContextProtocol.Protocol.McpTaskStatusNotificationParams @@ -624,6 +736,27 @@ lib/net10.0/ModelContextProtocol.Core.dll true + + CP0002 + F:ModelContextProtocol.Protocol.NotificationMethods.TaskStatusNotification + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + F:ModelContextProtocol.Protocol.RequestMethods.TasksCancel + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + F:ModelContextProtocol.Protocol.RequestMethods.TasksGet + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + CP0002 F:ModelContextProtocol.Protocol.RequestMethods.TasksList @@ -785,20 +918,6 @@ lib/net10.0/ModelContextProtocol.Core.dll true - - CP0002 - M:ModelContextProtocol.Protocol.CreateTaskResult.get_Task - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateTaskResult.set_Task(ModelContextProtocol.Protocol.McpTask) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - CP0002 M:ModelContextProtocol.Protocol.ElicitRequestParams.get_Task @@ -813,34 +932,6 @@ lib/net10.0/ModelContextProtocol.Core.dll true - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.#ctor - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.get_PollInterval - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.set_PollInterval(System.Nullable{System.TimeSpan}) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.set_Status(ModelContextProtocol.Protocol.McpTaskStatus) - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - CP0002 M:ModelContextProtocol.Protocol.ServerCapabilities.get_Tasks @@ -960,6 +1051,13 @@ lib/net10.0/ModelContextProtocol.Core.dll true + + CP0002 + M:ModelContextProtocol.Server.McpServerOptions.set_TaskStore(ModelContextProtocol.IMcpTaskStore) + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + CP0002 M:ModelContextProtocol.Server.McpServerToolAttribute.get_TaskSupport @@ -995,6 +1093,27 @@ lib/net8.0/ModelContextProtocol.Core.dll true + + CP0002 + F:ModelContextProtocol.Protocol.NotificationMethods.TaskStatusNotification + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + F:ModelContextProtocol.Protocol.RequestMethods.TasksCancel + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + F:ModelContextProtocol.Protocol.RequestMethods.TasksGet + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + CP0002 F:ModelContextProtocol.Protocol.RequestMethods.TasksList @@ -1156,20 +1275,6 @@ lib/net8.0/ModelContextProtocol.Core.dll true - - CP0002 - M:ModelContextProtocol.Protocol.CreateTaskResult.get_Task - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateTaskResult.set_Task(ModelContextProtocol.Protocol.McpTask) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - CP0002 M:ModelContextProtocol.Protocol.ElicitRequestParams.get_Task @@ -1184,34 +1289,6 @@ lib/net8.0/ModelContextProtocol.Core.dll true - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.#ctor - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.get_PollInterval - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.set_PollInterval(System.Nullable{System.TimeSpan}) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.set_Status(ModelContextProtocol.Protocol.McpTaskStatus) - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - CP0002 M:ModelContextProtocol.Protocol.ServerCapabilities.get_Tasks @@ -1331,6 +1408,13 @@ lib/net8.0/ModelContextProtocol.Core.dll true + + CP0002 + M:ModelContextProtocol.Server.McpServerOptions.set_TaskStore(ModelContextProtocol.IMcpTaskStore) + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + CP0002 M:ModelContextProtocol.Server.McpServerToolAttribute.get_TaskSupport @@ -1366,6 +1450,27 @@ lib/net9.0/ModelContextProtocol.Core.dll true + + CP0002 + F:ModelContextProtocol.Protocol.NotificationMethods.TaskStatusNotification + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + F:ModelContextProtocol.Protocol.RequestMethods.TasksCancel + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + F:ModelContextProtocol.Protocol.RequestMethods.TasksGet + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + CP0002 F:ModelContextProtocol.Protocol.RequestMethods.TasksList @@ -1527,20 +1632,6 @@ lib/net9.0/ModelContextProtocol.Core.dll true - - CP0002 - M:ModelContextProtocol.Protocol.CreateTaskResult.get_Task - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateTaskResult.set_Task(ModelContextProtocol.Protocol.McpTask) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - CP0002 M:ModelContextProtocol.Protocol.ElicitRequestParams.get_Task @@ -1555,34 +1646,6 @@ lib/net9.0/ModelContextProtocol.Core.dll true - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.#ctor - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.get_PollInterval - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.set_PollInterval(System.Nullable{System.TimeSpan}) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.set_Status(ModelContextProtocol.Protocol.McpTaskStatus) - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - CP0002 M:ModelContextProtocol.Protocol.ServerCapabilities.get_Tasks @@ -1702,6 +1765,13 @@ lib/net9.0/ModelContextProtocol.Core.dll true + + CP0002 + M:ModelContextProtocol.Server.McpServerOptions.set_TaskStore(ModelContextProtocol.IMcpTaskStore) + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + CP0002 M:ModelContextProtocol.Server.McpServerToolAttribute.get_TaskSupport @@ -1737,6 +1807,27 @@ lib/netstandard2.0/ModelContextProtocol.Core.dll true + + CP0002 + F:ModelContextProtocol.Protocol.NotificationMethods.TaskStatusNotification + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + F:ModelContextProtocol.Protocol.RequestMethods.TasksCancel + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + + CP0002 + F:ModelContextProtocol.Protocol.RequestMethods.TasksGet + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + CP0002 F:ModelContextProtocol.Protocol.RequestMethods.TasksList @@ -1898,20 +1989,6 @@ lib/netstandard2.0/ModelContextProtocol.Core.dll true - - CP0002 - M:ModelContextProtocol.Protocol.CreateTaskResult.get_Task - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.CreateTaskResult.set_Task(ModelContextProtocol.Protocol.McpTask) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - CP0002 M:ModelContextProtocol.Protocol.ElicitRequestParams.get_Task @@ -1926,34 +2003,6 @@ lib/netstandard2.0/ModelContextProtocol.Core.dll true - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.#ctor - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.get_PollInterval - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.set_PollInterval(System.Nullable{System.TimeSpan}) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0002 - M:ModelContextProtocol.Protocol.GetTaskResult.set_Status(ModelContextProtocol.Protocol.McpTaskStatus) - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - CP0002 M:ModelContextProtocol.Protocol.ServerCapabilities.get_Tasks @@ -2073,6 +2122,13 @@ lib/netstandard2.0/ModelContextProtocol.Core.dll true + + CP0002 + M:ModelContextProtocol.Server.McpServerOptions.set_TaskStore(ModelContextProtocol.IMcpTaskStore) + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + CP0002 M:ModelContextProtocol.Server.McpServerToolAttribute.get_TaskSupport @@ -2102,57 +2158,29 @@ true - CP0011 - F:ModelContextProtocol.Protocol.McpTaskStatus.Cancelled + CP0005 + M:ModelContextProtocol.Client.McpClient.ResolveInputRequestsAsync(System.Collections.Generic.IDictionary{System.String,ModelContextProtocol.Protocol.InputRequest},System.Threading.CancellationToken) lib/net10.0/ModelContextProtocol.Core.dll lib/net10.0/ModelContextProtocol.Core.dll true - CP0011 - F:ModelContextProtocol.Protocol.McpTaskStatus.Failed - lib/net10.0/ModelContextProtocol.Core.dll - lib/net10.0/ModelContextProtocol.Core.dll - true - - - CP0011 - F:ModelContextProtocol.Protocol.McpTaskStatus.Cancelled - lib/net8.0/ModelContextProtocol.Core.dll - lib/net8.0/ModelContextProtocol.Core.dll - true - - - CP0011 - F:ModelContextProtocol.Protocol.McpTaskStatus.Failed + CP0005 + M:ModelContextProtocol.Client.McpClient.ResolveInputRequestsAsync(System.Collections.Generic.IDictionary{System.String,ModelContextProtocol.Protocol.InputRequest},System.Threading.CancellationToken) lib/net8.0/ModelContextProtocol.Core.dll lib/net8.0/ModelContextProtocol.Core.dll true - CP0011 - F:ModelContextProtocol.Protocol.McpTaskStatus.Cancelled - lib/net9.0/ModelContextProtocol.Core.dll - lib/net9.0/ModelContextProtocol.Core.dll - true - - - CP0011 - F:ModelContextProtocol.Protocol.McpTaskStatus.Failed + CP0005 + M:ModelContextProtocol.Client.McpClient.ResolveInputRequestsAsync(System.Collections.Generic.IDictionary{System.String,ModelContextProtocol.Protocol.InputRequest},System.Threading.CancellationToken) lib/net9.0/ModelContextProtocol.Core.dll lib/net9.0/ModelContextProtocol.Core.dll true - CP0011 - F:ModelContextProtocol.Protocol.McpTaskStatus.Cancelled - lib/netstandard2.0/ModelContextProtocol.Core.dll - lib/netstandard2.0/ModelContextProtocol.Core.dll - true - - - CP0011 - F:ModelContextProtocol.Protocol.McpTaskStatus.Failed + CP0005 + M:ModelContextProtocol.Client.McpClient.ResolveInputRequestsAsync(System.Collections.Generic.IDictionary{System.String,ModelContextProtocol.Protocol.InputRequest},System.Threading.CancellationToken) lib/netstandard2.0/ModelContextProtocol.Core.dll lib/netstandard2.0/ModelContextProtocol.Core.dll true From 5c6e59a9d3026026b6a1676464ad62969c6d6566 Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Wed, 8 Jul 2026 15:38:01 -0700 Subject: [PATCH 10/16] Update tasks docs for the Extensions.Tasks API move The Tasks feature moved from ModelContextProtocol.Core to the new ModelContextProtocol.Extensions.Tasks package, which renamed several types and members. Updated docs/concepts/tasks/tasks.md and the task references in docs/concepts/stateless/stateless.md so the docfx cross references resolve and the samples match the current API: - Types moved to the ModelContextProtocol.Extensions.Tasks namespace. - Server enablement uses WithTasks(IMcpTaskStore) instead of the removed McpServerOptions.TaskStore property. - Custom tool handlers use CallToolWithAlternateHandler returning ResultOrAlternate. - Task scope uses the McpTasksServerExtensions.CreateMcpTaskScope extension. - Client calls use CallToolWithPollingAsync, CallToolAsTaskAsync, and the Get/Update/CancelTaskAsync client extensions; the stuck-poll threshold is now the maxConsecutiveStuckPolls parameter. --- docs/concepts/stateless/stateless.md | 8 +- docs/concepts/tasks/tasks.md | 156 +++++++++++++-------------- 2 files changed, 79 insertions(+), 85 deletions(-) diff --git a/docs/concepts/stateless/stateless.md b/docs/concepts/stateless/stateless.md index 6750443df..41d357144 100644 --- a/docs/concepts/stateless/stateless.md +++ b/docs/concepts/stateless/stateless.md @@ -590,7 +590,7 @@ In stateless mode, each HTTP request creates and disposes a short-lived `McpServ ## Tasks and session modes -[Tasks](xref:tasks) enable a "call-now, fetch-later" pattern for long-running tool calls. Task support depends on having an configured (`McpServerOptions.TaskStore`), and behavior differs between session modes. +[Tasks](xref:tasks) enable a "call-now, fetch-later" pattern for long-running tool calls. Task support depends on having an configured (enabled via `WithTasks`), and behavior differs between session modes. ### Stateless mode @@ -600,9 +600,9 @@ In stateless mode, there is no `SessionId`, so the task store does not apply ses ### Stateful mode -In stateful mode, the `IMcpTaskStore` receives the session's `SessionId` on every operation — `CreateTaskAsync`, `GetTaskAsync`, `ListTasksAsync`, `CancelTaskAsync`, etc. The built-in enforces session isolation: tasks created in one session cannot be accessed from another. +In stateful mode, the `IMcpTaskStore` receives the session's `SessionId` on every operation: `CreateTaskAsync`, `GetTaskAsync`, `ListTasksAsync`, `CancelTaskAsync`, etc. The built-in enforces session isolation: tasks created in one session cannot be accessed from another. -Tasks can outlive individual HTTP requests because the tool executes in the background after returning the initial `CreateTaskResult`. Task cleanup is governed by the task's TTL (time-to-live), not by session termination. However, the `InMemoryMcpTaskStore` loses all tasks if the server process restarts. For durable tasks, implement a custom backed by an external store. See [Implementing a custom task store](xref:tasks#implementing-a-custom-task-store) for guidance. +Tasks can outlive individual HTTP requests because the tool executes in the background after returning the initial `CreateTaskResult`. Task cleanup is governed by the task's TTL (time-to-live), not by session termination. However, the `InMemoryMcpTaskStore` loses all tasks if the server process restarts. For durable tasks, implement a custom backed by an external store. See [Implementing a custom task store](xref:tasks#implementing-a-custom-task-store) for guidance. ### Task cancellation vs request cancellation @@ -654,7 +654,7 @@ The `EventStreamStore` itself has TTL-based limits (default: 2-hour event expira ### With tasks (experimental) -[Tasks](xref:tasks) are an experimental feature that enables a "call-now, fetch-later" pattern for long-running tool calls. When a client sends a task-augmented `tools/call` request, the server creates a task record in the , starts the tool handler as a fire-and-forget background task, and returns the task ID immediately — the POST response completes **before the handler starts its real work**. +[Tasks](xref:tasks) are an experimental feature that enables a "call-now, fetch-later" pattern for long-running tool calls. When a client sends a task-augmented `tools/call` request, the server creates a task record in the , starts the tool handler as a fire-and-forget background task, and returns the task ID immediately, so the POST response completes **before the handler starts its real work**. This means: diff --git a/docs/concepts/tasks/tasks.md b/docs/concepts/tasks/tasks.md index 16e3a6dc2..5342946bc 100644 --- a/docs/concepts/tasks/tasks.md +++ b/docs/concepts/tasks/tasks.md @@ -18,7 +18,7 @@ and the client polls for status, optionally exchanging additional input along th A client opts into tasks on a per-request basis by including the `io.modelcontextprotocol/tasks` extension key in the request's `_meta`. When that opt-in is present, the server **may** respond -with a instead of the standard result +with a instead of the standard result (e.g., ). The client then polls `tasks/get` until the task reaches a terminal state. @@ -39,36 +39,35 @@ the extension opt-in. The SDK enforces this on the server side. └──→ Failed (terminal — JSON-RPC errors only) ``` - wire values are serialized in snake_case: + wire values are serialized in snake_case: `working`, `input_required`, `completed`, `cancelled`, `failed`. The discriminator field -on the response payload is `"task"` for +on the response payload is `"task"` for and `"complete"` for ordinary results. ### Server configuration #### Using the task store -The easiest way to enable tasks is to set an -on . -The SDK ships for development and tests: +Tasks support lives in the `ModelContextProtocol.Extensions.Tasks` package. The easiest way to +enable tasks is to call +on the server builder, passing an . +The SDK ships for development and tests: ```csharp #pragma warning disable MCPEXP001 -builder.Services.AddMcpServer(options => -{ - options.TaskStore = new InMemoryMcpTaskStore(); -}) -.WithTools(); +using ModelContextProtocol.Extensions.Tasks; + +builder.Services.AddMcpServer() + .WithTools() + .WithTasks(new InMemoryMcpTaskStore()); ``` -When a `TaskStore` is configured the SDK automatically: +When tasks are enabled with `WithTasks` the SDK automatically: -- Wires `tasks/get`, `tasks/update`, and `tasks/cancel` handlers from the store. Explicit - handlers in still take precedence - for any slot they fill. +- Wires the `tasks/get`, `tasks/update`, and `tasks/cancel` handlers from the store. - Advertises the `io.modelcontextprotocol/tasks` extension in . - Wraps each `[McpServerTool]` invocation so that, when the client opts in to the extension, @@ -81,40 +80,21 @@ When a `TaskStore` is configured the SDK automatically: `tasks/cancel`, so cancellation propagates cooperatively. For production scenarios that need durability, session isolation, multi-process routing, or -TTL-based cleanup, implement yourself +TTL-based cleanup, implement yourself (see [Implementing a custom task store](#implementing-a-custom-task-store) below). -#### Custom task handlers - -For full control without a store, set the handlers directly. Each handler is an - that receives an - with typed parameters: - -```csharp -options.Handlers.GetTaskHandler = (context, ct) => -{ - var taskId = context.Params!.TaskId; - // … look up state and return one of the GetTaskResult subtypes. - return new ValueTask(new WorkingTaskResult { TaskId = taskId, /* … */ }); -}; - -options.Handlers.UpdateTaskHandler = (context, ct) => /* return ValueTask */; -options.Handlers.CancelTaskHandler = (context, ct) => /* return ValueTask */; -``` - -> **Important**: configure all three lifecycle handlers (or use a `TaskStore`) before opting -> into task responses. If a tool handler returns a `CreateTaskResult` but no `tasks/get` -> handler is wired, the server throws `InvalidOperationException` at request time so misconfigured -> deployments fail loudly instead of shipping unpollable tasks. - #### Returning a task from a tool handler - -returns , so each invocation can choose -between an immediate result and a background task: +For full control without the store's auto-wrapping, set +. +It returns a , so each invocation can choose +between an immediate result and an alternate result such as a +: ```csharp -options.Handlers.CallToolWithTaskHandler = async (context, ct) => +using ModelContextProtocol.Extensions.Tasks; + +options.Handlers.CallToolWithAlternateHandler = async (context, ct) => { if (ShouldRunInline(context.Params!)) { @@ -122,7 +102,7 @@ options.Handlers.CallToolWithTaskHandler = async (context, ct) => } var taskId = await StartBackgroundWorkAsync(context.Params!, ct); - return new CreateTaskResult + var created = new CreateTaskResult { TaskId = taskId, Status = McpTaskStatus.Working, @@ -130,22 +110,32 @@ options.Handlers.CallToolWithTaskHandler = async (context, ct) => LastUpdatedAt = DateTimeOffset.UtcNow, PollIntervalMs = 1000, }; + + return new ResultOrAlternate(created, McpTasksJsonContext.Default.CreateTaskResult); }; ``` +> This low-level handler is mutually exclusive with `WithTasks`. When a store is configured, the +> SDK does the wrapping for you and throws `InvalidOperationException` if the alternate handler also +> returns an alternate. Use one mechanism or the other. When you return a task this way you are also +> responsible for serving `tasks/get`, `tasks/update`, and `tasks/cancel`, which the store provides +> automatically. + > -> and +> and > are mutually exclusive. Setting one while the other is already non-null throws > `InvalidOperationException` at the property setter. #### Task scope for server-initiated requests -When you start background work from a custom -(rather than the SDK's auto-wrapping), use +When you start background work from a custom +(rather than the SDK's auto-wrapping), use to route elicitation, sampling, and `roots/list` calls through the task store as input requests instead of direct JSON-RPC messages: ```csharp +using ModelContextProtocol.Extensions.Tasks; + using (server.CreateMcpTaskScope(taskId, taskStore)) { // ElicitAsync/SampleAsync/RequestRootsAsync calls in here are surfaced as @@ -156,13 +146,13 @@ using (server.CreateMcpTaskScope(taskId, taskStore)) `CreateMcpTaskScope` returns an `IDisposable` that restores the prior ambient context on `Dispose`. The scope is established automatically for `[McpServerTool]` methods that run via -`McpServerOptions.TaskStore`, so this API is only needed for custom handlers. +`WithTasks`, so this API is only needed for custom handlers. ### Client usage #### Automatic polling - + handles the full task lifecycle automatically: - Injects the `io.modelcontextprotocol/tasks` extension capability into the request's `_meta`. @@ -176,21 +166,25 @@ handles the full task lifecycle automatically: or throws on `Failed`/`Cancelled`. ```csharp -var result = await client.CallToolAsync( +using ModelContextProtocol.Extensions.Tasks; + +var result = await client.CallToolWithPollingAsync( new CallToolRequestParams { Name = "long-running-tool", Arguments = arguments }, - cancellationToken); + cancellationToken: cancellationToken); ``` #### Manual control -Use to receive the raw - without auto-polling, then drive the -lifecycle yourself using , -, and -: +Use to receive the raw + without auto-polling, then drive the +lifecycle yourself using , +, and +: ```csharp -var raw = await client.CallToolRawAsync(requestParams, cancellationToken); +using ModelContextProtocol.Extensions.Tasks; + +var raw = await client.CallToolAsTaskAsync(requestParams, cancellationToken); if (raw.IsTask) { var taskId = raw.TaskCreated!.TaskId; @@ -206,17 +200,17 @@ if (raw.IsTask) #### Stuck-task detector -`CallToolAsync` includes a safety net for misbehaving servers: if the task stays in - across many consecutive polls +`CallToolWithPollingAsync` includes a safety net for misbehaving servers: if the task stays in + across many consecutive polls without exposing any new input request keys (i.e. every previously requested input has already been resolved by the client and yet the server keeps returning `InputRequired`), the client gives up, issues a best-effort `tasks/cancel`, and throws . This guards against a server that never transitions out of `InputRequired` and prevents an unbounded poll loop. -The threshold defaults to `60` consecutive stuck polls and is configurable via -. The effective -wall-clock timeout is roughly `MaxConsecutiveStuckPolls * pollIntervalMs`, so tune the option +The threshold defaults to `60` consecutive stuck polls and is configurable via the +`maxConsecutiveStuckPolls` parameter on `CallToolWithPollingAsync`. The effective +wall-clock timeout is roughly `maxConsecutiveStuckPolls * pollIntervalMs`, so tune the value with the server-side poll cadence in mind. Setting it too low risks false positives for servers that are slow to surface follow-up input requests; setting it too high can mask misbehaving servers. @@ -224,11 +218,11 @@ servers. ### Input requests (multi-round-trip) When a task needs additional input from the client, the server transitions it to - and returns the outstanding -requests in . Each + and returns the outstanding +requests in . Each entry is an arbitrary key paired with a `{ method, params }` envelope representing an equivalent standalone server-to-client request. The client provides answers via -, keyed by the same identifiers. +, keyed by the same identifiers. Supported input request methods: @@ -241,34 +235,34 @@ Per SEP-2663: - Each input request key **must** be unique over the lifetime of the task. - Clients **should** deduplicate keys across polls so a request is only presented to the user - or model once. `CallToolAsync` does this automatically. + or model once. `CallToolWithPollingAsync` does this automatically. - Servers **should** ignore `inputResponses` entries whose key does not currently correspond to an outstanding request, including responses for terminal-state tasks. - follows this rule. + follows this rule. ### Implementing a custom task store -Implement for production scenarios. Key +Implement for production scenarios. Key requirements drawn from the SEP and the SDK contract: 1. **Thread safety** — every method may be called concurrently. 2. **Idempotent terminal transitions** — - , - , and - must be no-ops on a task + , + , and + must be no-ops on a task that is already in a terminal state so a late cancellation cannot overwrite a result. 3. **`InputResponseReceived` event** — after persisting an input response inside - , raise - + , raise + for each resolved entry. This is the only mechanism that wakes a pending `server.ElicitAsync`/`server.SampleAsync` call waiting inside a task scope. In distributed deployments where a different server instance receives the `tasks/update`, the event must be propagated to the originating server (for example via Redis pub/sub, SignalR, or a custom transport). 4. **Strong-consistency on `CreateTaskAsync`** — - must not return until the + must not return until the task is durably persisted, so that a subsequent - with the returned task ID + with the returned task ID resolves immediately — even from a different process or node. Stores backed by eventually-consistent storage must wait for the write to become visible (quorum acknowledgement, write-through, etc.) before returning. Required by SEP-2663 §306. @@ -310,12 +304,12 @@ public sealed class MyTaskStore : IMcpTaskStore ### Status semantics - is the terminal status whenever the + is the terminal status whenever the underlying request produced its standard result, *including a with `IsError = true`*. Per SEP-2663, tool-level error results are not promoted to `Failed`. - is reserved for JSON-RPC protocol-level + is reserved for JSON-RPC protocol-level errors during execution — for example, a malformed request, or an unhandled exception in a custom handler that the SDK converts to a JSON-RPC error. Use for @@ -341,7 +335,7 @@ In the built-in SDK pipeline, when a task is wrapped by a configured `TaskStore` #### Immutable store design - uses immutable record snapshots with + uses immutable record snapshots with compare-and-swap updates for lock-free thread safety. `InputRequests` and `InputResponses` are exposed as `ImmutableDictionary<,>` so observers cannot mutate internal state. @@ -357,13 +351,13 @@ responsible for handling — or rejecting — the input requests surfaced throug - **Server-push task status notifications (SEP-2575)**: not yet implemented. Clients rely on polling exclusively. -- **Lazy task creation**: when a tool runs through `TaskStore`, the store's - is invoked eagerly before +- **Lazy task creation**: when a tool runs through the task store, the store's + is invoked eagerly before the inner handler runs, so tools that complete inline still incur a store write. There is currently no built-in deferral. - **Mid-execution promotion to task**: an `[McpServerTool]` method cannot start executing synchronously and then transition its remaining work to a background task. Use a custom - + if you need that pattern. - **`roots/list` as an input request**: the server SDK routes `RequestRootsAsync` through the task channel when called from inside a task scope, but the client SDK does not currently From 3e7ca321304461f566319b70ad47297317b4c172 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Thu, 9 Jul 2026 13:39:38 -0400 Subject: [PATCH 11/16] Release v2.0.0-preview.2 (#1695) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Directory.Build.props | 2 +- src/PACKAGE.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 5758c4936..5e1e4951e 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -6,7 +6,7 @@ https://github.com/modelcontextprotocol/csharp-sdk git 2.0.0 - preview.1 + preview.2 ModelContextProtocol © Model Context Protocol a Series of LF Projects, LLC. ModelContextProtocol;mcp;ai;llm diff --git a/src/PACKAGE.md b/src/PACKAGE.md index 1495cd1fb..749a21f47 100644 --- a/src/PACKAGE.md +++ b/src/PACKAGE.md @@ -4,7 +4,7 @@ The official C# SDK for the [Model Context Protocol](https://modelcontextprotocol.io/), enabling .NET applications, services, and libraries to implement and interact with MCP clients and servers. Please visit the [API documentation](https://csharp.sdk.modelcontextprotocol.io/api/ModelContextProtocol.html) for more details on available functionality. -See the [release notes](https://github.com/modelcontextprotocol/csharp-sdk/releases/tag/v2.0.0-preview.1) for what's new in this version. +See the [release notes](https://github.com/modelcontextprotocol/csharp-sdk/releases/tag/v2.0.0-preview.2) for what's new in this version. ## Packages From b2a40128fc9ae419f5cd525f2c5b719e7f0d311c Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:49:24 -0700 Subject: [PATCH 12/16] Re-enable http-custom-headers conformance scenario (#1655) (#1691) Co-authored-by: Tarek Mahmoud Sayed --- package-lock.json | 8 +- package.json | 2 +- tests/Common/Utils/NodeHelpers.cs | 146 +++++++++++++++++- .../ClientConformanceTests.cs | 22 ++- 4 files changed, 168 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index fc400cc42..6cc1c80aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,7 @@ "packages": { "": { "dependencies": { - "@modelcontextprotocol/conformance": "0.2.0-alpha.5", + "@modelcontextprotocol/conformance": "0.2.0-alpha.8", "@modelcontextprotocol/server-everything": "2026.1.26", "@modelcontextprotocol/server-memory": "2026.1.26" } @@ -23,9 +23,9 @@ } }, "node_modules/@modelcontextprotocol/conformance": { - "version": "0.2.0-alpha.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.2.0-alpha.5.tgz", - "integrity": "sha512-sYxNHKk/m7Vx0/XxKulbcmgqz7wH2tXIge9u1G1CzpluaZHTci966m5dw7wGyQKx7IR3/V+/hAAGXc8ZqBMoyw==", + "version": "0.2.0-alpha.8", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.2.0-alpha.8.tgz", + "integrity": "sha512-ktbvq6ftvs23TjZ/WVmTKZHue8dqoUF5bBG3Qd5pbawfTyuveZw93o07uQij8tslBlccpPVS5jE212G+SnLo2A==", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index 3dbb9fba1..c1ad1b2b2 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "private": true, "description": "Pinned npm dependencies for MCP C# SDK integration and conformance tests", "dependencies": { - "@modelcontextprotocol/conformance": "0.2.0-alpha.5", + "@modelcontextprotocol/conformance": "0.2.0-alpha.8", "@modelcontextprotocol/server-everything": "2026.1.26", "@modelcontextprotocol/server-memory": "2026.1.26" } diff --git a/tests/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs index 45ecdcde2..aad326ba4 100644 --- a/tests/Common/Utils/NodeHelpers.cs +++ b/tests/Common/Utils/NodeHelpers.cs @@ -191,6 +191,19 @@ public static bool IsNodeInstalled() public static bool HasSep2243Scenarios() => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)); + /// + /// Checks whether the installed conformance package contains a spec-conformant + /// http-custom-headers scenario. Prereleases 0.2.0-alpha.5 through 0.2.0-alpha.7 + /// annotated a number-typed parameter with x-mcp-header, which SEP-2243 + /// forbids; a conformant client excludes that tool, so every positive check in the + /// scenario fails. Conformance PR #371 fixed the scenario and shipped it in 0.2.0-alpha.8, + /// so this gate requires at least that version. Unlike , + /// this comparison honors the semver prerelease so older 0.2.0 prereleases are skipped + /// rather than failing spuriously. + /// + public static bool HasConformantCustomHeadersScenario() + => IsInstalledConformanceVersionAtLeast("0.2.0-alpha.8"); + /// /// Checks whether the SEP-2549 "caching" conformance scenario (added in conformance /// PR #275) is available, by reading the installed conformance package version @@ -256,7 +269,138 @@ private static bool HasInstalledConformanceVersionAtLeast(Version minimumVersion } /// - /// Runs the conformance runner ("conformance <arguments>") in server mode and returns + /// Returns when the conformance package installed in node_modules + /// has a semver precedence greater than or equal to , + /// honoring the prerelease component (e.g. "0.2.0-alpha.8"). Returns + /// when no version can be determined. + /// + private static bool IsInstalledConformanceVersionAtLeast(string minimumVersion) + { + var installed = GetInstalledConformanceVersionString(); + return installed is not null && CompareSemVer(installed, minimumVersion) >= 0; + } + + /// + /// Reads the raw version string of the conformance package installed in node_modules, + /// preserving any prerelease/build suffix. Returns if it cannot be + /// determined. + /// + private static string? GetInstalledConformanceVersionString() + { + try + { + var repoRoot = FindRepoRoot(); + var packageJsonPath = Path.Combine( + repoRoot, "node_modules", "@modelcontextprotocol", "conformance", "package.json"); + + if (!File.Exists(packageJsonPath)) + { + return null; + } + + using var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(packageJsonPath)); + if (json.RootElement.TryGetProperty("version", out var versionElement)) + { + return versionElement.GetString(); + } + + return null; + } + catch + { + return null; + } + } + + /// + /// Compares two semantic version strings by precedence, honoring the prerelease component + /// per the SemVer 2.0.0 rules used here (numeric identifiers compare numerically, a version + /// with a prerelease has lower precedence than the same version without one, and a shorter + /// set of prerelease identifiers has lower precedence when all preceding ones are equal). + /// Build metadata (after '+') is ignored. Returns a negative value when + /// precedes , zero when equal, and a positive value otherwise. + /// + private static int CompareSemVer(string a, string b) + { + var (coreA, preA) = SplitSemVer(a); + var (coreB, preB) = SplitSemVer(b); + + var coreCompare = coreA.CompareTo(coreB); + if (coreCompare != 0) + { + return coreCompare; + } + + // A version without a prerelease outranks one with a prerelease. + if (preA.Length == 0 && preB.Length == 0) + { + return 0; + } + if (preA.Length == 0) + { + return 1; + } + if (preB.Length == 0) + { + return -1; + } + + var count = Math.Min(preA.Length, preB.Length); + for (var i = 0; i < count; i++) + { + var idA = preA[i]; + var idB = preB[i]; + var numA = int.TryParse(idA, out var na); + var numB = int.TryParse(idB, out var nb); + + int cmp; + if (numA && numB) + { + cmp = na.CompareTo(nb); + } + else if (numA) + { + // Numeric identifiers always have lower precedence than alphanumeric ones. + cmp = -1; + } + else if (numB) + { + cmp = 1; + } + else + { + cmp = string.CompareOrdinal(idA, idB); + } + + if (cmp != 0) + { + return cmp; + } + } + + return preA.Length.CompareTo(preB.Length); + } + + /// + /// Splits a semver string into its numeric core (major.minor.patch) and its prerelease + /// identifiers, ignoring any build metadata after '+'. Missing core components default to 0. + /// + private static (Version Core, string[] Prerelease) SplitSemVer(string version) + { + var withoutBuild = version.Split(new[] { '+' }, 2)[0]; + var parts = withoutBuild.Split(new[] { '-' }, 2); + var prerelease = parts.Length > 1 && parts[1].Length > 0 + ? parts[1].Split('.') + : Array.Empty(); + + var coreParts = parts[0].Split('.'); + int Part(int index) => index < coreParts.Length && int.TryParse(coreParts[index], out var v) ? v : 0; + var core = new Version(Part(0), Part(1), Part(2)); + + return (core, prerelease); + } + + /// whether it succeeded along with the captured stdout/stderr. Centralizes the process /// plumbing (output capture, a 5-minute timeout, and the Windows libuv-shutdown fallback) /// shared by the server-side conformance tests. diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs index 2990b3d83..e21bf4e67 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs @@ -17,6 +17,7 @@ public class ClientConformanceTests // Public static property required for SkipUnless attribute public static bool IsNodeInstalled => NodeHelpers.IsNodeInstalled(); public static bool HasSep2243Scenarios => NodeHelpers.HasSep2243Scenarios(); + public static bool HasConformantCustomHeadersScenario => NodeHelpers.HasConformantCustomHeadersScenario(); public ClientConformanceTests(ITestOutputHelper output) { @@ -66,10 +67,6 @@ public async Task RunConformanceTest(string scenario) [Theory(Skip = "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0).", SkipUnless = nameof(HasSep2243Scenarios))] [InlineData("http-standard-headers")] [InlineData("http-invalid-tool-headers")] - // Commented out: the upstream scenario annotates a "number"-typed parameter with x-mcp-header, - // which SEP-2243 forbids, so the client rejects the tool and sends no Mcp-Param-* headers, - // failing every positive check. Re-enable once a conformant conformance package ships (#1655). - // [InlineData("http-custom-headers")] public async Task RunConformanceTest_Sep2243(string scenario) { // Run the conformance test suite @@ -80,6 +77,23 @@ public async Task RunConformanceTest_Sep2243(string scenario) $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); } + // The http-custom-headers scenario needs a tighter gate than the other SEP-2243 scenarios: + // conformance 0.2.0-alpha.5 through 0.2.0-alpha.7 shipped it with an x-mcp-header on a + // number-typed parameter (forbidden by SEP-2243), which a conformant client excludes, + // failing every positive check. It was fixed upstream in 0.2.0-alpha.8 (conformance #371), + // so require at least that version to avoid spurious failures on older 0.2.0 prereleases. + [Theory(Skip = "Conformant http-custom-headers scenario not available (requires conformance package >= 0.2.0-alpha.8).", SkipUnless = nameof(HasConformantCustomHeadersScenario))] + [InlineData("http-custom-headers")] + public async Task RunConformanceTest_Sep2243_CustomHeaders(string scenario) + { + // Run the conformance test suite + var result = await RunClientConformanceScenario(scenario); + + // Report the results + Assert.True(result.Success, + $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); + } + private async Task<(bool Success, string Output, string Error)> RunClientConformanceScenario(string scenario) { // Construct an absolute path to the conformance client executable From a8d374b2800f0e1230263c0490e282c27ff05336 Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Sun, 12 Jul 2026 13:56:10 -0400 Subject: [PATCH 13/16] Fix request-scoped draft client capabilities (#1685) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Tarek Mahmoud Sayed --- .../Server/DestinationBoundMcpServer.cs | 54 ++++- .../Server/McpServer.cs | 25 ++- .../Server/McpServerImpl.cs | 43 ++-- .../Client/McpClientMetaTests.cs | 194 +++++++++++++++++- .../Protocol/UrlElicitationTests.cs | 30 ++- 5 files changed, 306 insertions(+), 40 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs index b8f96237a..7aab34826 100644 --- a/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs +++ b/src/ModelContextProtocol.Core/Server/DestinationBoundMcpServer.cs @@ -5,18 +5,66 @@ namespace ModelContextProtocol.Server; #pragma warning disable MCPEXP002 -internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport? transport) : McpServer +internal sealed class DestinationBoundMcpServer(McpServerImpl server, ITransport? transport, JsonRpcMessageContext? requestContext = null) : McpServer #pragma warning restore MCPEXP002 { + private readonly bool _isJuly2026OrLaterRequest = server.IsJuly2026OrLaterProtocolRequest(requestContext); + private readonly ClientCapabilities? _requestClientCapabilities = requestContext?.ClientCapabilities; + private readonly Implementation? _requestClientInfo = requestContext?.ClientInfo; + public override string? SessionId => transport?.SessionId ?? server.SessionId; public override string? NegotiatedProtocolVersion => server.NegotiatedProtocolVersion; - public override ClientCapabilities? ClientCapabilities => server.ClientCapabilities; - public override Implementation? ClientInfo => server.ClientInfo; public override McpServerOptions ServerOptions => server.ServerOptions; public override IServiceProvider? Services => server.Services; [Obsolete(Obsoletions.DeprecatedLogging_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)] public override LoggingLevel? LoggingLevel => server.LoggingLevel; + public override ClientCapabilities? ClientCapabilities + { + get + { + // In stateless transport mode, a single request does not have a persistent bidirectional channel. + // Server-to-client requests (sampling, roots, elicitation) are unsupported in this mode and the + // capability gates rely on a null ClientCapabilities value to report that unsupported-state path. + if (!server.HasStatefulTransport()) + { + return null; + } + + // On protocol revision 2026-07-28+, client capabilities are request-scoped (_meta on each request) + // and must not be inferred from prior requests. Missing per-request capabilities therefore means + // "no declared capabilities for this request", represented by an empty object. A fresh instance is + // returned deliberately: ClientCapabilities is a mutable DTO handed to user handlers, so a shared + // static empty instance could be mutated and leak across requests. + if (_isJuly2026OrLaterRequest) + { + return _requestClientCapabilities ?? new ClientCapabilities(); + } + + // Legacy protocol behavior uses session-scoped capabilities established during initialize (or + // pre-populated migration data), so ignore per-request values and return the server session state. + return server.ClientCapabilities; + } + } + + public override Implementation? ClientInfo + { + get + { + // On protocol revision 2026-07-28+, client info is request-scoped (carried in each request's _meta), + // mirroring how ClientCapabilities is resolved above. Return only this request's declared value and + // do not fall back to shared session state, which under a stateful transport could belong to a + // different concurrent request. + if (_isJuly2026OrLaterRequest) + { + return _requestClientInfo; + } + + // Legacy protocol behavior uses session-scoped client info established during initialize. + return server.ClientInfo; + } + } + /// /// Gets or sets the MRTR context for the current request, if any. /// Set by when an MRTR-aware handler invocation is in progress. diff --git a/src/ModelContextProtocol.Core/Server/McpServer.cs b/src/ModelContextProtocol.Core/Server/McpServer.cs index 5f8ebf69a..a51010dc0 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.cs @@ -21,9 +21,19 @@ protected McpServer() /// /// /// - /// These capabilities are established during the initialization handshake and indicate - /// which features the client supports, such as sampling, roots, and other - /// protocol-specific functionality. + /// On protocol revisions that use the initialize handshake (2025-11-25 and earlier), these + /// capabilities are established once during initialization and are session-scoped: they are available both + /// on the root and on the server exposed to request handlers. + /// + /// + /// On the 2026-07-28 revision and later (SEP-2575) there is no initialize handshake; the client + /// declares its capabilities per-request in _meta, and the server MUST NOT infer them from previous + /// requests. In that mode this property is only meaningful on the request-scoped server accessed via + /// the Server property of the passed to a handler; on the + /// root (for example one constructed manually over a + /// ) it is . + /// It is also in stateless transport mode, where server-to-client requests are + /// unsupported. /// /// /// Server implementations can check these capabilities to determine which features @@ -38,7 +48,14 @@ protected McpServer() /// /// /// This property contains identification information about the client that has connected to this server, - /// including its name and version. This information is provided by the client during initialization. + /// including its name and version. + /// + /// + /// On protocol revisions that use the initialize handshake (2025-11-25 and earlier) this + /// information is provided once during initialization and is session-scoped. On the 2026-07-28 + /// revision and later it is carried per-request in _meta, so read it from the request-scoped server + /// accessed via the Server property of the passed to a handler + /// rather than from the root . /// /// /// Server implementations can use this information for logging, tracking client versions, diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index f6eb0d60e..d98a6c7c8 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -152,15 +152,17 @@ void Register(McpServerPrimitiveCollection? collection, /// /// Wraps so that, for every JSON-RPC request, a built-in filter first - /// synchronizes server-side state (, - /// , ) from the per-request _meta - /// values projected onto and validates the per-request protocol - /// version, before delegating to the user-supplied incoming filters. + /// synchronizes server-side state (, ) + /// from the per-request _meta values projected onto and + /// validates the per-request protocol version, before delegating to the user-supplied incoming filters. /// /// /// Under the 2026-07-28 protocol revision (SEP-2575) there is no initialize handshake, so these values - /// MUST be populated per-request. For legacy clients the per-request values are absent and the built-in - /// filter is a no-op (the values were captured during the initialize handler). + /// MUST be populated per-request. Per-request client capabilities and client info are consumed request-scoped + /// by and are not read from server-wide state by request handlers. The + /// shared write below is best-effort and used only to derive the session endpoint + /// name for logging/telemetry. For legacy clients the per-request values are absent and the built-in filter is + /// a no-op (the values were captured during the initialize handler). /// private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner) { @@ -185,23 +187,16 @@ private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner SetNegotiatedProtocolVersion(protocolVersion); } - if (context.ClientCapabilities is { } clientCapabilities && IsJuly2026OrLaterProtocol() && HasStatefulTransport()) - { - // Under the 2026-07-28 revision the per-request _meta envelope carries the client's FULL - // capabilities (SEP-2575), so a plain overwrite is correct. The IsJuly2026OrLaterProtocol() gate - // makes any legacy per-request envelope a no-op (legacy capabilities stay as the - // initialize handshake established them); the HasStatefulTransport() gate keeps - // _clientCapabilities null under StreamableHttpServerTransport { Stateless = true } - // (where the same server instance handles every request, so persisting per-request - // capability state would both leak across requests and break the StatelessServerTests - // invariant that surfaces the "X is not supported in stateless mode" errors). - _clientCapabilities = clientCapabilities; - } - if (context.ClientInfo is { } clientInfo && (_clientInfo is null || !string.Equals(_clientInfo.Name, clientInfo.Name, StringComparison.Ordinal) || !string.Equals(_clientInfo.Version, clientInfo.Version, StringComparison.Ordinal))) { + // This shared write is best-effort and used only to derive the session endpoint name for + // logging/telemetry. It is intentionally NOT read by request handlers on 2026-07-28+ sessions: + // DestinationBoundMcpServer resolves ClientInfo (and ClientCapabilities) request-scoped from + // the per-request _meta so concurrent requests never observe each other's values. Under a + // draft stateful session with differing per-request client info, the last writer wins here, + // which only affects the logged endpoint name and never the request-scoped values handlers see. _clientInfo = clientInfo; endpointNameNeedsRefresh = true; } @@ -1627,7 +1622,7 @@ async ValueTask InvokeScopedAsync( private DestinationBoundMcpServer CreateDestinationBoundServer(JsonRpcRequest jsonRpcRequest) { - var server = new DestinationBoundMcpServer(this, jsonRpcRequest.Context?.RelatedTransport); + var server = new DestinationBoundMcpServer(this, jsonRpcRequest.Context?.RelatedTransport, jsonRpcRequest.Context); if (_mrtrContextsByRequestId.TryRemove(jsonRpcRequest.Id, out var mrtrContext)) { @@ -1740,7 +1735,7 @@ private JsonRpcMessageFilter BuildMessageFilterPipeline(IList { // 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); + var context = new MessageContext(new DestinationBoundMcpServer(this, message.Context.RelatedTransport, message.Context), message); await current(context, cancellationToken).ConfigureAwait(false); }; }; @@ -1795,8 +1790,12 @@ internal bool HasStatefulTransport() => /// Used to gate the SEP-2663 Tasks extension, which only interoperates on the 2026-07-28 revision. /// private bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request) => + IsJuly2026OrLaterProtocolRequest(request?.Context); + + /// + internal bool IsJuly2026OrLaterProtocolRequest(JsonRpcMessageContext? requestContext) => McpHttpHeaders.IsJuly2026OrLaterProtocolVersion( - request?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion); + requestContext?.ProtocolVersion ?? NegotiatedProtocolVersion); /// public override bool IsMrtrSupported => ClientSupportsMrtr() || HasStatefulTransport(); diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs index e1e9a08db..3afbb52eb 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs @@ -3,6 +3,7 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; +using System.Text.Json; using System.Text.Json.Nodes; namespace ModelContextProtocol.Tests.Client; @@ -15,6 +16,9 @@ public class McpClientMetaTests : ClientServerTestBase private readonly TaskCompletionSource _initializeMeta = new(); + private readonly TaskCompletionSource<(Implementation? Info, ClientCapabilities? Capabilities)> _outgoingFilterObserved = + new(TaskCreationOptions.RunContinuationsAsynchronously); + public McpClientMetaTests(ITestOutputHelper outputHelper) : base(outputHelper) { @@ -39,6 +43,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer // Capture the _meta the server receives on the initialize request so tests can // assert that McpClientOptions.InitializeMeta is threaded through the handshake. mcpServerBuilder.WithMessageFilters(filters => + { filters.AddIncomingFilter(next => async (context, cancellationToken) => { if (context.JsonRpcMessage is JsonRpcRequest { Method: RequestMethods.Initialize } request) @@ -47,7 +52,23 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer } await next(context, cancellationToken); - })); + }); + + // Capture the request-scoped client info/capabilities observed while an outgoing response flows + // through the outgoing filter pipeline. Gated on a unique client name so only the dedicated test + // triggers it. This exercises that DestinationBoundMcpServer resolves per-request _meta for + // responses (whose Context is the originating request's Context), not just requests. + filters.AddOutgoingFilter(next => async (context, cancellationToken) => + { + if (context.JsonRpcMessage is JsonRpcResponse && + context.Server.ClientInfo is { Name: "outgoing-filter-client" } info) + { + _outgoingFilterObserved.TrySetResult((info, context.Server.ClientCapabilities)); + } + + await next(context, cancellationToken); + }); + }); } [Fact] @@ -116,6 +137,177 @@ public async Task ToolCallWithMetaFields() Assert.Contains("bar baz", textContent.Text); } + [Fact] + public async Task ConcurrentToolCalls_WithPerRequestClientCapabilities_UseRequestScopedCapabilities() + { + var withSamplingReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var withoutSamplingReady = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var allowSamplingChecks = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( + async (string requestId, RequestContext context, CancellationToken cancellationToken) => + { + if (requestId == "with") + { + withSamplingReady.TrySetResult(true); + } + else if (requestId == "without") + { + withoutSamplingReady.TrySetResult(true); + } + else + { + throw new ArgumentException($"Unexpected request id '{requestId}'."); + } + + await allowSamplingChecks.Task.WaitAsync(TestConstants.DefaultTimeout, cancellationToken); + + return context.Server.ClientCapabilities?.Sampling is null ? + $"{requestId}:sampling-absent" : + $"{requestId}:sampling-present"; + }, + new() { Name = "meta_sampling_tool" })); + + await using McpClient client = await CreateMcpClientForServer(); + + var withSamplingRequest = new CallToolRequestParams + { + Name = "meta_sampling_tool", + Arguments = new Dictionary + { + ["requestId"] = JsonDocument.Parse("\"with\"").RootElement.Clone(), + }, + Meta = new JsonObject + { + [MetaKeys.ClientCapabilities] = JsonSerializer.SerializeToNode( + new ClientCapabilities { Sampling = new SamplingCapability() }, + McpJsonUtilities.DefaultOptions), + }, + }; + + var withoutSamplingRequest = new CallToolRequestParams + { + Name = "meta_sampling_tool", + Arguments = new Dictionary + { + ["requestId"] = JsonDocument.Parse("\"without\"").RootElement.Clone(), + }, + Meta = new JsonObject + { + [MetaKeys.ClientCapabilities] = JsonSerializer.SerializeToNode( + new ClientCapabilities(), + McpJsonUtilities.DefaultOptions), + }, + }; + + Task withSamplingTask = client.CallToolAsync(withSamplingRequest, TestContext.Current.CancellationToken).AsTask(); + Task withoutSamplingTask = client.CallToolAsync(withoutSamplingRequest, TestContext.Current.CancellationToken).AsTask(); + + await Task.WhenAll(withSamplingReady.Task, withoutSamplingReady.Task).WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + allowSamplingChecks.TrySetResult(true); + + CallToolResult withSamplingResult = await withSamplingTask; + CallToolResult withoutSamplingResult = await withoutSamplingTask; + + var withSamplingText = Assert.IsType(Assert.Single(withSamplingResult.Content)).Text; + var withoutSamplingText = Assert.IsType(Assert.Single(withoutSamplingResult.Content)).Text; + + Assert.Equal("with:sampling-present", withSamplingText); + Assert.Equal("without:sampling-absent", withoutSamplingText); + } + + [Fact] + public async Task ToolCall_UnderJuly2026Protocol_ObservesRequestScopedClientInfo() + { + Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( + (RequestContext context) => + { + var clientInfo = context.Server.ClientInfo; + return clientInfo is null ? + "client-info-absent" : + $"{clientInfo.Name}:{clientInfo.Version}"; + }, + new() { Name = "client_info_tool" })); + + // The 2026-07-28+ client stamps its ClientInfo onto every request's _meta, so the tool must observe + // the per-request value resolved by DestinationBoundMcpServer rather than server-only session state. + var clientOptions = new McpClientOptions + { + ClientInfo = new Implementation { Name = "request-scoped-client", Version = "9.9.9" }, + }; + + await using McpClient client = await CreateMcpClientForServer(clientOptions); + + var result = await client.CallToolAsync("client_info_tool", cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Equal("request-scoped-client:9.9.9", text); + } + + [Fact] + public async Task RootServer_UnderJuly2026Protocol_HasNoClientCapabilities_ButHandlerObservesThem() + { + ClientCapabilities? handlerObservedCapabilities = null; + + Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( + (RequestContext context) => + { + handlerObservedCapabilities = context.Server.ClientCapabilities; + return "ok"; + }, + new() { Name = "capability_probe_tool" })); + + var clientOptions = new McpClientOptions + { + Handlers = new McpClientHandlers + { + ElicitationHandler = (_, _) => new ValueTask(new ElicitResult()), + }, + }; + + await using McpClient client = await CreateMcpClientForServer(clientOptions); + + // Under the 2026-07-28 revision capabilities are request-scoped, so the root server (outside any + // request) never exposes them, whereas a request handler observes the per-request _meta values. + Assert.Null(Server.ClientCapabilities); + + await client.CallToolAsync("capability_probe_tool", cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(handlerObservedCapabilities); + Assert.NotNull(handlerObservedCapabilities!.Elicitation); + Assert.Null(Server.ClientCapabilities); + } + + [Fact] + public async Task OutgoingMessageFilter_UnderJuly2026Protocol_ObservesRequestScopedClientInfoAndCapabilities() + { + Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( + () => "ok", + new() { Name = "outgoing_probe_tool" })); + + var clientOptions = new McpClientOptions + { + ClientInfo = new Implementation { Name = "outgoing-filter-client", Version = "3.2.1" }, + Handlers = new McpClientHandlers + { + ElicitationHandler = (_, _) => new ValueTask(new ElicitResult()), + }, + }; + + await using McpClient client = await CreateMcpClientForServer(clientOptions); + + await client.CallToolAsync("outgoing_probe_tool", cancellationToken: TestContext.Current.CancellationToken); + + var (info, capabilities) = await _outgoingFilterObserved.Task + .WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + + Assert.NotNull(info); + Assert.Equal("outgoing-filter-client", info!.Name); + Assert.Equal("3.2.1", info.Version); + Assert.NotNull(capabilities); + Assert.NotNull(capabilities!.Elicitation); + } + [Fact] public async Task ResourceReadWithMetaFields() { diff --git a/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs index bf4c67d21..4963a0a10 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs @@ -190,6 +190,16 @@ await request.Server.ElicitAsync(new() }); } + // These tests assert on the root server's ClientCapabilities (see AssertServerElicitationCapability), + // which is only session-scoped under the initialize-handshake revisions. Pin to the latest such revision + // so the capabilities negotiated during initialize are observable on the root McpServer. Request-scoped + // capability behavior under the 2026-07-28 revision is covered by McpClientMetaTests. + private Task CreateLegacyClientForServer(McpClientOptions clientOptions) + { + clientOptions.ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion; + return CreateMcpClientForServer(clientOptions); + } + [Fact] public async Task Can_Elicit_OutOfBand_With_Url() { @@ -198,7 +208,7 @@ public async Task Can_Elicit_OutOfBand_With_Url() string? capturedMessage = null; var completionNotification = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -283,7 +293,7 @@ public async Task Can_Elicit_OutOfBand_With_Url() [Fact] public async Task UrlElicitation_User_Can_Decline() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -322,7 +332,7 @@ public async Task UrlElicitation_User_Can_Decline() [Fact] public async Task UrlElicitation_User_Can_Cancel() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -360,7 +370,7 @@ public async Task UrlElicitation_User_Can_Cancel() [Fact] public async Task UrlElicitation_Defaults_To_Unsupported_When_Handler_Provided() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Handlers = new McpClientHandlers() { @@ -385,7 +395,7 @@ public async Task UrlElicitation_Defaults_To_Unsupported_When_Handler_Provided() [Fact] public async Task FormElicitation_Defaults_To_Supported_When_Handler_Provided() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Handlers = new McpClientHandlers() { @@ -406,7 +416,7 @@ public async Task FormElicitation_Defaults_To_Supported_When_Handler_Provided() [Fact] public async Task UrlElicitation_BlankCapability_Allows_Only_Form() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -435,7 +445,7 @@ public async Task UrlElicitation_BlankCapability_Allows_Only_Form() [Fact] public async Task FormElicitation_UrlOnlyCapability_NotSupported() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -474,7 +484,7 @@ public async Task UrlElicitation_Requires_ElicitationId_For_Url_Mode() { var elicitationHandlerCalled = false; - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -504,7 +514,7 @@ public async Task UrlElicitation_Requires_ElicitationId_For_Url_Mode() [Fact] public async Task UrlElicitationRequired_Exception_Propagates_To_Client() { - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { @@ -532,7 +542,7 @@ public async Task FormElicitation_Requires_RequestedSchema() { var elicitationHandlerCalled = false; - await using McpClient client = await CreateMcpClientForServer(new McpClientOptions + await using McpClient client = await CreateLegacyClientForServer(new McpClientOptions { Capabilities = new ClientCapabilities { From fd38f51e273868d9cf802bcc90b04e6e2801023e Mon Sep 17 00:00:00 2001 From: Stephen Halter Date: Sun, 12 Jul 2026 14:30:49 -0700 Subject: [PATCH 14/16] Tighten draft protocol negotiation boundaries (#1692) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Tarek Mahmoud Sayed --- docs/concepts/elicitation/elicitation.md | 2 +- docs/concepts/mrtr/mrtr.md | 4 +- docs/concepts/roots/roots.md | 2 +- docs/concepts/sampling/sampling.md | 2 +- docs/concepts/stateless/stateless.md | 16 +- docs/concepts/transports/transports.md | 2 +- src/Common/McpHttpHeaders.cs | 42 --- src/Common/McpProtocolVersions.cs | 113 +++++++ .../HttpServerTransportOptions.cs | 8 +- .../ModelContextProtocol.AspNetCore.csproj | 1 + .../StreamableHttpHandler.cs | 181 +++++++--- .../Client/McpClientImpl.cs | 141 +++++--- .../Client/McpClientOptions.cs | 24 +- .../StreamableHttpClientSessionTransport.cs | 16 +- src/ModelContextProtocol.Core/McpSession.cs | 2 +- .../McpSessionHandler.cs | 24 +- .../ModelContextProtocol.Core.csproj | 1 + .../Protocol/DiscoverResult.cs | 9 +- .../Protocol/InitializeRequestParams.cs | 9 +- .../Protocol/InitializeResult.cs | 4 +- .../Protocol/JsonRpcMessage.cs | 2 +- .../Protocol/RequestMethods.cs | 8 +- .../Server/McpServerImpl.cs | 317 ++++++++++++++++-- .../Server/McpServerOptions.cs | 34 +- tests/Common/Utils/NodeHelpers.cs | 106 +++--- .../CachingConformanceTests.cs | 4 +- .../ClientConformanceTests.cs | 15 +- .../HttpHeaderConformanceTests.cs | 73 ++-- .../July2026ProtocolHttpFallbackTests.cs | 163 +++++++-- .../July2026ProtocolHttpHandlerTests.cs | 62 ++-- .../July2026ProtocolStatefulFallbackTests.cs | 16 +- ...delContextProtocol.AspNetCore.Tests.csproj | 1 + .../MrtrProtocolTests.cs | 53 ++- .../RawHttpConformanceTests.cs | 123 ++++++- .../RequestAbortCancellationTests.cs | 4 +- .../ServerConformanceTests.cs | 6 +- .../StreamableHttpServerConformanceTests.cs | 54 +-- .../Program.cs | 6 +- .../Client/July2026ProtocolConnectionTests.cs | 34 +- .../Client/July2026ProtocolFallbackTests.cs | 216 +++++++++--- .../July2026ProtocolListMetaEmissionTests.cs | 20 +- .../Client/McpClientCreationTests.cs | 2 +- .../Client/McpClientTests.cs | 10 +- .../Client/McpRequestHeadersTests.cs | 5 +- .../ClientIntegrationTests.cs | 9 +- .../McpServerBuilderExtensionsPromptsTests.cs | 6 +- ...rverBuilderExtensionsRequestFilterTests.cs | 2 +- ...cpServerBuilderExtensionsResourcesTests.cs | 6 +- .../McpServerBuilderExtensionsToolsTests.cs | 6 +- .../ModelContextProtocol.Tests.csproj | 1 + .../Protocol/CacheableResultWarningTests.cs | 28 +- .../Protocol/DiscoverResultCacheableTests.cs | 2 +- .../Protocol/UrlElicitationTests.cs | 2 +- .../Server/McpServerTests.cs | 87 ++++- .../Server/NegotiatedProtocolVersionTests.cs | 206 +++++++++++- .../Server/PingProtocolGatingTests.cs | 10 +- .../Server/RawStreamConformanceTests.cs | 21 +- .../Server/SubscriptionsListenTests.cs | 8 +- .../Server/TaskProtocolGatingTests.cs | 13 +- 59 files changed, 1755 insertions(+), 589 deletions(-) create mode 100644 src/Common/McpProtocolVersions.cs diff --git a/docs/concepts/elicitation/elicitation.md b/docs/concepts/elicitation/elicitation.md index 60530d85d..4e14a2d1e 100644 --- a/docs/concepts/elicitation/elicitation.md +++ b/docs/concepts/elicitation/elicitation.md @@ -175,7 +175,7 @@ Here's an example implementation of how a console application might handle elici [MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. In that revision, the server-to-client `elicitation/create` request method is removed; the recommended way to ask the user for input from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> `ElicitAsync` throws `InvalidOperationException("Elicitation is not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `2026-07-28` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `elicitation/create` request flow. For code that needs to run on stateless servers — including all `2026-07-28` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. +> `ElicitAsync` throws `InvalidOperationException("Elicitation is not supported in stateless mode.")` whenever the server is running stateless — including Streamable HTTP requests served under `2026-07-28` with `Stateless = true`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `elicitation/create` request flow; an HTTP server set to `Stateless = false` refuses `2026-07-28` so dual-path clients can fall back before using that flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. For example: diff --git a/docs/concepts/mrtr/mrtr.md b/docs/concepts/mrtr/mrtr.md index 5044e6d51..e0d4f8d0f 100644 --- a/docs/concepts/mrtr/mrtr.md +++ b/docs/concepts/mrtr/mrtr.md @@ -33,7 +33,7 @@ MRTR is useful when: ## Opting in -MRTR activates when both peers negotiate protocol revision **`2026-07-28`**. The C# SDK client prefers `2026-07-28` by default — it probes with `server/discover` and falls back to a legacy `initialize` handshake only when the server doesn't support it. Servers accept `2026-07-28` automatically when a client offers it. No experimental flags are required; pinning `ProtocolVersion` to a legacy revision opts back out. +MRTR activates when both peers negotiate protocol revision **`2026-07-28`**. The C# SDK client prefers `2026-07-28` by default — it probes with `server/discover` and falls back to an `initialize` handshake only when the server doesn't support it. Stateless HTTP servers accept `2026-07-28` automatically when a client offers it; HTTP servers configured with `Stateless = false` refuse that revision with `UnsupportedProtocolVersion` so dual-path clients can fall back to a session-capable revision. No experimental flags are required; pinning `ProtocolVersion` to an initialize-capable revision opts back out. ```csharp // Client — the SDK prefers 2026-07-28 (and therefore MRTR) by default. @@ -283,4 +283,4 @@ The SDK supports `InputRequiredException` across two protocol revisions and two Under the current protocol revision (`2025-06-18` and earlier), stdio and stateful Streamable HTTP keep `ClientCapabilities` populated, so the legacy methods work normally and remain the recommended way to do one-shot client interactions. Under `2026-07-28`, the spec removes those request methods from Streamable HTTP entirely; the SDK still allows the legacy methods on `2026-07-28` stdio sessions because stdio is implicitly single-process / stateful and the client handler is wired up regardless of negotiated revision. `InputRequiredException` is the way to write tools that work on every supported configuration. -Because `2026-07-28` removes `Mcp-Session-Id` (SEP-2567) and the `initialize` handshake (SEP-2575), Streamable HTTP runs statelessly whenever a client speaks `2026-07-28`. The `Stateful` row for `2026-07-28` in the compatibility matrix above therefore applies only to stdio — a server explicitly set to `Stateless = false` still serves `2026-07-28` requests without a session and creates a legacy session only when an older client falls back to `initialize`. +Because `2026-07-28` removes `Mcp-Session-Id` (SEP-2567) and the `initialize` handshake (SEP-2575), Streamable HTTP can serve that revision only through the stateless path. The `Stateful` row for `2026-07-28` in the compatibility matrix above therefore applies to stdio and other non-HTTP stateful sessions; an HTTP server explicitly set to `Stateless = false` refuses `2026-07-28` with `UnsupportedProtocolVersion` and creates a session only when an older client falls back to `initialize`. diff --git a/docs/concepts/roots/roots.md b/docs/concepts/roots/roots.md index bb0218f97..de9b9b0ac 100644 --- a/docs/concepts/roots/roots.md +++ b/docs/concepts/roots/roots.md @@ -109,7 +109,7 @@ server.RegisterNotificationHandler( [MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. In that revision, the server-to-client `roots/list` request method is removed; the recommended way to ask the client for its roots from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> `RequestRootsAsync` throws `InvalidOperationException("Roots are not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `2026-07-28` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `roots/list` request flow. For code that needs to run on stateless servers — including all `2026-07-28` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. +> `RequestRootsAsync` throws `InvalidOperationException("Roots are not supported in stateless mode.")` whenever the server is running stateless — including Streamable HTTP requests served under `2026-07-28` with `Stateless = true`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `roots/list` request flow; an HTTP server set to `Stateless = false` refuses `2026-07-28` so dual-path clients can fall back before using that flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. For example: diff --git a/docs/concepts/sampling/sampling.md b/docs/concepts/sampling/sampling.md index 825acfb0a..7b8eb8e59 100644 --- a/docs/concepts/sampling/sampling.md +++ b/docs/concepts/sampling/sampling.md @@ -126,7 +126,7 @@ Sampling requires the client to advertise the `sampling` capability. This is han [MRTR](xref:mrtr) is the SEP-2322 mechanism for server-driven input requests, finalized in protocol revision `2026-07-28`. In that revision, the server-to-client `sampling/createMessage` request method is removed; the recommended way to ask the client to sample from a server handler is to throw and let the SDK emit an on the wire. > [!IMPORTANT] -> `SampleAsync` and `AsSamplingChatClient` throw `InvalidOperationException("Sampling is not supported in stateless mode.")` whenever the server is running stateless — which includes every Streamable HTTP server under `2026-07-28` once that revision is forced to stateless-only in a future PR. Stdio servers and current-protocol stateful Streamable HTTP servers continue to work via the legacy server-to-client `sampling/createMessage` request flow. For code that needs to run on stateless servers — including all `2026-07-28` Streamable HTTP servers going forward — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. +> `SampleAsync` and `AsSamplingChatClient` throw `InvalidOperationException("Sampling is not supported in stateless mode.")` whenever the server is running stateless — including Streamable HTTP requests served under `2026-07-28` with `Stateless = true`. Stdio servers and initialize-handshake stateful Streamable HTTP sessions continue to work via the initialize-era server-to-client `sampling/createMessage` request flow; an HTTP server set to `Stateless = false` refuses `2026-07-28` so dual-path clients can fall back before using that flow. For code that needs to run on stateless servers — including `2026-07-28` Streamable HTTP — throw `InputRequiredException` from your handler instead. It works under both protocols and both session modes. For example: diff --git a/docs/concepts/stateless/stateless.md b/docs/concepts/stateless/stateless.md index 6750443df..d27cd2a8d 100644 --- a/docs/concepts/stateless/stateless.md +++ b/docs/concepts/stateless/stateless.md @@ -28,11 +28,11 @@ When sessions are enabled (`Stateless = false`), the server creates and tracks a ## Forward and backward compatibility -The `Stateless` property is the single most important setting for forward-proofing your MCP server. The default is now `Stateless = true` (sessions disabled), which is the forward-compatible setting for the `2026-07-28` protocol revision and beyond. Stateless servers still respond to legacy clients on `2025-11-25` and earlier — the SDK keeps the `initialize` + `Mcp-Session-Id` handshake available for those clients — but they cannot use the session-dependent features ([unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, per-client isolation). Server-to-client requests are the exception: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr), though MRTR requires the unratified `2026-07-28` revision and is far less widely supported than session-based requests. We recommend every server set `Stateless` explicitly rather than relying on the default: +The `Stateless` property is the single most important setting for forward-proofing your MCP server. The default is now `Stateless = true` (sessions disabled), which is the forward-compatible setting for the `2026-07-28` protocol revision and beyond. Stateless servers still respond to clients on `2025-11-25` and earlier — the SDK keeps the `initialize` + `Mcp-Session-Id` handshake available for those clients — but they cannot use the session-dependent features ([unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, per-client isolation). Server-to-client requests are the exception: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr), though MRTR requires the unratified `2026-07-28` revision and is far less widely supported than session-based requests. We recommend every server set `Stateless` explicitly rather than relying on the default: -- **`Stateless = true`** — the current default and the forward-compatible choice. Your server opts out of sessions entirely and the `Mcp-Session-Id` header is never sent or honored. The `2026-07-28` protocol revision drops the `initialize` handshake and `Mcp-Session-Id` from the wire format entirely, so this is the only configuration that lets the server respond to `2026-07-28` clients without falling back to legacy handling. If you don't need [unsolicited notifications](#how-streamable-http-delivers-messages), server-to-client requests, or session-scoped state, this is the setting to use today. +- **`Stateless = true`** — the current default and the forward-compatible choice. Your server opts out of sessions entirely and the `Mcp-Session-Id` header is never sent or used. The `2026-07-28` protocol revision drops the `initialize` handshake and `Mcp-Session-Id` from the wire format entirely, so this is the only configuration that lets the server respond to `2026-07-28` clients without falling back to initialize-handshake handling. If you don't need [unsolicited notifications](#how-streamable-http-delivers-messages), server-to-client requests, or session-scoped state, this is the setting to use today. -- **`Stateless = false`** — the right choice when your server depends on sessions for [unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, or per-client isolation, none of which work without a session. Setting this explicitly protects your server from a future default change, and the [MCP specification requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) that clients use sessions when a server's `initialize` response includes an `Mcp-Session-Id` header, so compliant clients always honor your server's session. Server-to-client requests no longer force a session: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr) (see [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions)). MRTR is only as available as the unratified `2026-07-28` revision, however, so keep a session if you need server-to-client requests against clients that don't speak it. Note that even with `Stateless = false`, a `2026-07-28` request is still served without a session because the protocol has no session header; the stateful path activates only when a client falls back to a legacy revision. +- **`Stateless = false`** — the right choice when your server depends on sessions for [unsolicited notifications](#how-streamable-http-delivers-messages), resource subscriptions, or per-client isolation, none of which work without a session. Setting this explicitly protects your server from a future default change, and the [MCP specification requires](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) that clients use sessions when a server's `initialize` response includes an `Mcp-Session-Id` header, so compliant clients always honor your server's session. Server-to-client requests no longer force a session: [elicitation](xref:elicitation) — and the now-deprecated [sampling](xref:sampling) and [roots](xref:roots) — can run statelessly through [MRTR](xref:mrtr) (see [Stateless alternatives for server-to-client interactions](#stateless-alternatives-for-server-to-client-interactions)). MRTR is only as available as the unratified `2026-07-28` revision, however, so keep a session if you need server-to-client requests against clients that don't speak it. Note that with `Stateless = false`, a `2026-07-28` request is refused with `UnsupportedProtocolVersion`; the stateful path activates only when a client falls back to an initialize-capable revision. > [!TIP] @@ -42,18 +42,18 @@ The `Stateless` property is the single most important setting for forward-proofi The `2026-07-28` protocol revision goes further than `Stateless = true`: it removes the `initialize` handshake (SEP-2575) and the `Mcp-Session-Id` header (SEP-2567) from the wire format entirely. Clients bootstrap by sending `server/discover` instead, and every request carries the negotiated protocol version in the `MCP-Protocol-Version` HTTP header (HTTP transport) or the `_meta.io.modelcontextprotocol/protocolVersion` JSON-RPC field (every transport). -**Server side.** With `Stateless = true` (the default), the SDK already meets `2026-07-28` on the wire. Any HTTP POST that arrives with the `2026-07-28` `MCP-Protocol-Version` header is routed through the stateless path automatically — no session is created, no `Mcp-Session-Id` is returned, and the `GET` and `DELETE` endpoints are not mapped. Legacy clients that still send `initialize` on the same endpoint continue to work in stateless mode for the lifetime of that single POST. With `Stateless = false`, the server still falls back to legacy session creation when the client speaks `2025-11-25` or earlier — but a `2026-07-28` request (which carries no session) on a stateful server is refused with a `-32022 UnsupportedProtocolVersion` error, so a dual-era client downgrades to the legacy `initialize` handshake and obtains a session. A `2026-07-28` request that carries an `Mcp-Session-Id` is always rejected, since the revision has no session concept. +**Server side.** With `Stateless = true` (the default), the SDK already meets `2026-07-28` on the wire. Any HTTP POST that arrives with the `2026-07-28` `MCP-Protocol-Version` header is routed through the stateless path automatically — no session is created, no `Mcp-Session-Id` is returned, and the `GET` and `DELETE` endpoints are not mapped. Clients that still send `initialize` on the same endpoint continue to work in stateless mode for the lifetime of that single POST. With `Stateless = false`, the server still creates HTTP sessions when the client speaks `2025-11-25` or earlier — but a `2026-07-28` request on a stateful server is refused with a `-32022 UnsupportedProtocolVersion` error, so a dual-path client downgrades to the `initialize` handshake and obtains a session. If a `2026-07-28` request carries an `Mcp-Session-Id`, the server ignores the header and still does not echo or mint a session ID for that request. -**Stateful options marked obsolete.** Because Streamable HTTP no longer supports sessions starting with the `2026-07-28` revision, the stateful-only knobs on — `IdleTimeout`, `MaxIdleSessionCount`, `EventStreamStore`, `SessionMigrationHandler`, and `PerSessionExecutionContext` — are now marked `[Obsolete]` with diagnostic `MCP9006` to signal that they only apply to legacy-protocol back-compat. You can still set them — the warning is informational — and they continue to govern stateful behavior for legacy clients. +**Stateful options marked obsolete.** Because Streamable HTTP no longer supports sessions starting with the `2026-07-28` revision, the stateful-only knobs on — `IdleTimeout`, `MaxIdleSessionCount`, `EventStreamStore`, `SessionMigrationHandler`, and `PerSessionExecutionContext` — are now marked `[Obsolete]` with diagnostic `MCP9006` to signal that they only apply to initialize-handshake back-compat. You can still set them — the warning is informational — and they continue to govern stateful behavior for initialize-capable clients. **Client side — automatic fallback.** Clients automatically probe `2026-07-28` first and fall back to the `initialize` handshake when the server doesn't support it: -- **HTTP**: the client sends its first request with the `2026-07-28` `MCP-Protocol-Version` header. If the server returns HTTP `400` with anything other than a structured `-32022` / `-32021` / `-32020` JSON-RPC error, the client switches to the legacy `initialize` flow on the same endpoint. -- **stdio**: the client sends a `server/discover` probe with a 5-second timeout. A `DiscoverResult` confirms `2026-07-28`; a `-32022` error with a `supported` payload triggers a retry at the highest mutually-supported version; anything else — including a timeout — falls back to legacy `initialize` on the same stdin/stdout. The SDK does not relaunch the server process. +- **HTTP**: the client sends its first request with the `2026-07-28` `MCP-Protocol-Version` header. If the server returns HTTP `400` with anything other than a structured `-32022` / `-32021` / `-32020` JSON-RPC error, the client switches to the `initialize` flow on the same endpoint. +- **stdio**: the client sends a `server/discover` probe with a 5-second timeout. A `DiscoverResult` confirms `2026-07-28`; a `-32022` error with a `supported` payload triggers a retry at the highest mutually-supported initialize-capable version; anything else — including a timeout — falls back to `initialize` on the same stdin/stdout. The SDK does not relaunch the server process. The era is cached per instance, so the probe cost is paid only on the first connect. -**Opting out of fallback.** Pin to `2026-07-28` when you want the client to refuse to fall back. A non-null `ProtocolVersion` is also treated as the minimum, so the connect call throws an instead of silently degrading to a legacy revision. This is useful for strict-modern production code and for tests that need to assert `2026-07-28`-only behavior. To try several versions yourself, leave `ProtocolVersion` unset (the default) or retry the connection with a different value. +**Opting out of fallback.** Pin to `2026-07-28` when you want the client to refuse to fall back. A non-null `ProtocolVersion` is also treated as the minimum, so the connect call throws an instead of silently degrading to an initialize-capable revision. This is useful for strict `2026-07-28` production code and for tests that need to assert `2026-07-28`-only behavior. To try several versions yourself, leave `ProtocolVersion` unset (the default) or retry the connection with a different value. ```csharp var clientOptions = new McpClientOptions diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index 354299ce1..85ccec209 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -183,7 +183,7 @@ app.MapMcp(); app.Run(); ``` -By default, the HTTP transport uses **stateful sessions** — the server assigns an `Mcp-Session-Id` to each client and tracks session state in memory. For most servers, **stateless mode is recommended** instead. It simplifies deployment, enables horizontal scaling without session affinity, and avoids issues with clients that don't send the `Mcp-Session-Id` header. We recommend setting `Stateless` explicitly (rather than relying on the current default) for [forward compatibility](xref:stateless#forward-and-backward-compatibility). See [Sessions](xref:stateless) for a detailed guide on when to use stateless vs. stateful mode, configure session options, and understand [cancellation and disposal](xref:stateless#cancellation-and-disposal) behavior during shutdown. +By default, the HTTP transport runs **statelessly** — the server does not assign an `Mcp-Session-Id` or track transport session state in memory. This simplifies deployment, enables horizontal scaling without session affinity, and matches the `2026-07-28` Streamable HTTP wire format. Set `Stateless = false` explicitly when your server needs stateful sessions for unsolicited notifications, resource subscriptions, or per-client isolation. See [Sessions](xref:stateless) for a detailed guide on when to use stateless vs. stateful mode, configure session options, and understand [cancellation and disposal](xref:stateless#cancellation-and-disposal) behavior during shutdown. #### Host name validation diff --git a/src/Common/McpHttpHeaders.cs b/src/Common/McpHttpHeaders.cs index 31800e2fa..c08326e20 100644 --- a/src/Common/McpHttpHeaders.cs +++ b/src/Common/McpHttpHeaders.cs @@ -9,25 +9,6 @@ namespace ModelContextProtocol.Protocol; /// internal static class McpHttpHeaders { - /// - /// The 2026-07-28 MCP protocol revision (SEP-2575 + SEP-2567). It removed the initialize - /// handshake and Mcp-Session-Id, so Streamable HTTP no longer has sessions; it also enabled - /// MRTR (SEP-2322) and made the standard MCP request headers (Mcp-Method, Mcp-Name) - /// required. Behaviors that began at this revision are gated by ordinal-comparing the per-request - /// version against it (see ), so it underpins the more - /// semantically named helpers. It is also the latest revision this SDK supports, so clients prefer it - /// by default. - /// - public const string July2026ProtocolVersion = "2026-07-28"; - - /// - /// The 2025-11-25 MCP protocol revision: the latest revision that still supports Streamable HTTP - /// sessions (the initialize handshake and Mcp-Session-Id); newer revisions remove them. - /// It is the default version for the legacy initialize and session-resume code paths, and the - /// version the server advertises when a peer requests an unsupported version on the legacy handshake. - /// - public const string November2025ProtocolVersion = "2025-11-25"; - /// The session identifier header. public const string SessionId = "Mcp-Session-Id"; @@ -71,27 +52,4 @@ internal static class McpHttpHeaders /// internal const string ToolContextKey = "Mcp.Tool"; - /// - /// Returns if the given protocol version is - /// or later, the revision that removed the initialize handshake and Streamable HTTP sessions. - /// Protocol versions are ISO-8601 dates, so an ordinal comparison orders them chronologically. - /// - internal static bool IsJuly2026OrLaterProtocolVersion(string? protocolVersion) - => !string.IsNullOrEmpty(protocolVersion) - && StringComparer.Ordinal.Compare(protocolVersion, July2026ProtocolVersion) >= 0; - - /// - /// Returns if the given protocol version requires standard MCP request headers - /// (Mcp-Method, Mcp-Name). - /// - public static bool SupportsStandardHeaders(string? protocolVersion) - => IsJuly2026OrLaterProtocolVersion(protocolVersion); - - /// - /// Returns if the negotiated protocol version reports unresolvable - /// resource URIs with the standard JSON-RPC (-32602) - /// rather than the legacy (-32002). - /// - internal static bool UseInvalidParamsForMissingResource(string? protocolVersion) - => IsJuly2026OrLaterProtocolVersion(protocolVersion); } diff --git a/src/Common/McpProtocolVersions.cs b/src/Common/McpProtocolVersions.cs new file mode 100644 index 000000000..09b1f5b3d --- /dev/null +++ b/src/Common/McpProtocolVersions.cs @@ -0,0 +1,113 @@ +namespace ModelContextProtocol.Protocol; + +/// +/// Internal helpers for MCP protocol revision strings and protocol-era behavior gates. +/// +internal static class McpProtocolVersions +{ + /// + /// The 2026-07-28 MCP protocol revision (SEP-2575 + SEP-2567). It removed the initialize + /// handshake and Mcp-Session-Id, so Streamable HTTP no longer has sessions; it also enabled + /// MRTR (SEP-2322) and made the standard MCP request headers (Mcp-Method, Mcp-Name) + /// required. Behaviors that began at this revision are gated by ordinal-comparing the per-request + /// version against it (see ), so it underpins the more + /// semantically named helpers. It is also the latest revision this SDK supports, so clients prefer it + /// by default. + /// + public const string July2026ProtocolVersion = "2026-07-28"; + + /// + /// The 2025-11-25 MCP protocol revision: the latest revision that still supports Streamable HTTP + /// sessions (the initialize handshake and Mcp-Session-Id); newer revisions remove them. + /// It is the default version for the initialize and session-resume code paths, and the version + /// the server advertises when a peer requests an unsupported version on the initialize handshake. + /// + public const string November2025ProtocolVersion = "2025-11-25"; + + /// The 2025-06-18 MCP protocol revision. + public const string June2025ProtocolVersion = "2025-06-18"; + + /// The 2025-03-26 MCP protocol revision. + public const string March2025ProtocolVersion = "2025-03-26"; + + /// The 2024-11-05 MCP protocol revision. + public const string November2024ProtocolVersion = "2024-11-05"; + + /// + /// Protocol versions that still use the initialize handshake. + /// + internal static readonly string[] InitializeHandshakeProtocolVersions = + [ + November2024ProtocolVersion, + March2025ProtocolVersion, + June2025ProtocolVersion, + November2025ProtocolVersion, + ]; + + /// + /// Protocol versions that use per-request metadata instead of the initialize handshake. + /// + internal static readonly string[] PerRequestMetadataProtocolVersions = + [ + July2026ProtocolVersion, + ]; + + /// + /// All protocol versions supported by this implementation. + /// + internal static readonly string[] SupportedProtocolVersions = + [ + .. InitializeHandshakeProtocolVersions, + .. PerRequestMetadataProtocolVersions, + ]; + + /// + /// Returns if the given protocol version is + /// or later, the revision that removed the initialize handshake and Streamable HTTP sessions. + /// Protocol versions are ISO-8601 dates, so an ordinal comparison orders them chronologically. + /// + internal static bool IsJuly2026OrLaterProtocolVersion(string? protocolVersion) + => !string.IsNullOrEmpty(protocolVersion) + && StringComparer.Ordinal.Compare(protocolVersion, July2026ProtocolVersion) >= 0; + + /// + /// Returns if the given protocol version is supported by this implementation. + /// + internal static bool IsSupportedProtocolVersion(string? protocolVersion) + => protocolVersion is not null && SupportedProtocolVersions.Contains(protocolVersion); + + /// + /// Returns if the given protocol version is available through the + /// initialize handshake. + /// + internal static bool SupportsInitializeHandshake(string? protocolVersion) + => protocolVersion is not null && InitializeHandshakeProtocolVersions.Contains(protocolVersion); + + /// + /// Returns if the given protocol version requires the handshake-free + /// per-request metadata path. + /// + internal static bool RequiresPerRequestMetadata(string? protocolVersion) + => IsJuly2026OrLaterProtocolVersion(protocolVersion); + + /// + /// Returns if the given protocol version requires standard MCP request headers + /// (Mcp-Method, Mcp-Name). + /// + internal static bool RequiresStandardHeaders(string? protocolVersion) + => RequiresPerRequestMetadata(protocolVersion); + + /// + /// Returns if the given protocol version supports Streamable HTTP sessions. + /// + internal static bool SupportsHttpSessions(string? protocolVersion) + => !RequiresPerRequestMetadata(protocolVersion); + + /// + /// Returns if the negotiated protocol version reports unresolvable + /// resource URIs with the standard JSON-RPC (-32602) + /// rather than the legacy (-32002). + /// + internal static bool UseInvalidParamsForMissingResource(string? protocolVersion) + => IsJuly2026OrLaterProtocolVersion(protocolVersion); +} diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs index e0c8a8826..024772240 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs @@ -64,10 +64,10 @@ public class HttpServerTransportOptions /// Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions: /// the revision removed Mcp-Session-Id (SEP-2567), so over HTTP its requests are only ever served /// when this property is . When it is , such a request is - /// refused with a -32022 UnsupportedProtocolVersion error so that a dual-era client downgrades to - /// the legacy initialize handshake and obtains the session the server was configured to provide. - /// A request that carries an Mcp-Session-Id is always rejected by the 2026-07-28 and later - /// revisions, regardless of this property's value. + /// refused with a -32022 UnsupportedProtocolVersion error so that a dual-path client downgrades to + /// the initialize handshake and obtains the session the server was configured to provide. + /// A request that carries an Mcp-Session-Id on the 2026-07-28 and later revisions is ignored; + /// the server must not mint or echo session IDs for those revisions. /// /// public bool Stateless { get; set; } = true; diff --git a/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj b/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj index 44bac1bc9..4c46acdd1 100644 --- a/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj +++ b/src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj @@ -25,6 +25,7 @@ + diff --git a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs index 0ecf7ab37..f7db4630e 100644 --- a/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs +++ b/src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs @@ -13,6 +13,7 @@ using System.Security.Claims; using System.Security.Cryptography; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.AspNetCore; @@ -32,24 +33,15 @@ internal sealed class StreamableHttpHandler( /// /// All protocol versions supported by this implementation. - /// Keep in sync with McpSessionHandler.SupportedProtocolVersions in ModelContextProtocol.Core. /// - private static readonly HashSet s_supportedProtocolVersions = - [ - "2024-11-05", - "2025-03-26", - "2025-06-18", - McpHttpHeaders.November2025ProtocolVersion, - McpHttpHeaders.July2026ProtocolVersion, - ]; + private static readonly string[] s_supportedProtocolVersions = McpProtocolVersions.SupportedProtocolVersions; /// /// The supported protocol versions that still allow Streamable HTTP sessions (excluding 2026-07-28 and - /// later). Used when refusing a 2026-07-28 request on a stateful (Stateless = false) server so a dual-era - /// client falls back to a legacy initialize handshake instead of retrying the 2026-07-28 version. + /// later). Used when refusing a 2026-07-28 request on a stateful (Stateless = false) server so a dual-path + /// client falls back to the initialize handshake instead of retrying the 2026-07-28 version. /// - private static readonly string[] s_sessionSupportingProtocolVersions = - [.. s_supportedProtocolVersions.Where(static v => !McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(v))]; + private static readonly string[] s_sessionSupportingProtocolVersions = McpProtocolVersions.InitializeHandshakeProtocolVersions; private static readonly JsonTypeInfo s_messageTypeInfo = GetRequiredJsonTypeInfo(); private static readonly JsonTypeInfo s_errorTypeInfo = GetRequiredJsonTypeInfo(); @@ -63,7 +55,8 @@ internal sealed class StreamableHttpHandler( public async Task HandlePostRequestAsync(HttpContext context) { - if (!ValidateProtocolVersionHeader(context, out var protocolVersionError)) + var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion); + if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, out var protocolVersionError)) { await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); return; @@ -111,6 +104,12 @@ await WriteJsonRpcErrorAsync(context, // Notifications carry no id, so this stays default (null), which is correct. var requestId = message is JsonRpcRequest jsonRpcRequest ? jsonRpcRequest.Id : default; + if (!ValidateProtocolVersionEnvelope(context, message, out var protocolVersionEnvelopeError)) + { + await WriteJsonRpcErrorDetailAsync(context, protocolVersionEnvelopeError, StatusCodes.Status400BadRequest, requestId); + return; + } + if (!ValidateMcpHeaders(context, message, mcpServerOptionsSnapshot.Value.ToolCollection, out var errorMessage)) { await WriteJsonRpcErrorAsync(context, errorMessage, StatusCodes.Status400BadRequest, (int)McpErrorCode.HeaderMismatch, requestId); @@ -137,7 +136,8 @@ await WriteJsonRpcErrorAsync(context, public async Task HandleGetRequestAsync(HttpContext context) { - if (!ValidateProtocolVersionHeader(context, out var protocolVersionError)) + var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion); + if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, out var protocolVersionError)) { await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); return; @@ -148,11 +148,12 @@ public async Task HandleGetRequestAsync(HttpContext context) // The 2026-07-28 revision (SEP-2575) removes the standalone HTTP GET endpoint for unsolicited // server-to-client messages; clients use subscriptions/listen (POST) instead. Because Streamable HTTP // no longer has sessions (SEP-2567), the GET is invalid whether or not it carries an Mcp-Session-Id. - if (IsJuly2026OrLaterProtocol(context)) + if (RequiresPerRequestMetadataProtocol(context)) { + context.Response.Headers.Allow = HttpMethods.Post; await WriteJsonRpcErrorAsync(context, - "Bad Request: The GET endpoint is not supported by the 2026-07-28 and later protocol revisions. Use subscriptions/listen via POST instead.", - StatusCodes.Status400BadRequest); + "Method Not Allowed: The GET endpoint is not supported by the 2026-07-28 and later protocol revisions. Use subscriptions/listen via POST instead.", + StatusCodes.Status405MethodNotAllowed); return; } @@ -254,7 +255,8 @@ private static async Task HandleResumePostResponseStreamAsync(HttpContext contex public async Task HandleDeleteRequestAsync(HttpContext context) { - if (!ValidateProtocolVersionHeader(context, out var protocolVersionError)) + var configuredSupportedProtocolVersions = GetConfiguredSupportedProtocolVersions(mcpServerOptionsSnapshot.Value.ProtocolVersion); + if (!ValidateProtocolVersionHeader(context, configuredSupportedProtocolVersions, out var protocolVersionError)) { await WriteJsonRpcErrorDetailAsync(context, protocolVersionError, StatusCodes.Status400BadRequest); return; @@ -264,11 +266,12 @@ public async Task HandleDeleteRequestAsync(HttpContext context) // Starting with the 2026-07-28 revision, Streamable HTTP has no sessions to terminate (SEP-2567), // so the DELETE is invalid whether or not it carries an Mcp-Session-Id. - if (IsJuly2026OrLaterProtocol(context)) + if (RequiresPerRequestMetadataProtocol(context)) { + context.Response.Headers.Allow = HttpMethods.Post; await WriteJsonRpcErrorAsync(context, - "Bad Request: The DELETE endpoint is not supported by the 2026-07-28 and later protocol revisions.", - StatusCodes.Status400BadRequest); + "Method Not Allowed: The DELETE endpoint is not supported by the 2026-07-28 and later protocol revisions.", + StatusCodes.Status405MethodNotAllowed); return; } @@ -374,25 +377,14 @@ await WriteJsonRpcErrorAsync(context, private async ValueTask GetOrCreateSessionAsync(HttpContext context, JsonRpcMessage message, RequestId requestId = default) { - var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); - // The 2026-07-28 revision removes the Mcp-Session-Id header and the session concept (SEP-2567) // and the initialize handshake (SEP-2575), so over HTTP it never has a session, with no exceptions: - if (IsJuly2026OrLaterProtocol(context)) + if (RequiresPerRequestMetadataProtocol(context)) { - if (!string.IsNullOrEmpty(sessionId)) - { - // A request carrying an Mcp-Session-Id is non-conformant under the 2026-07-28 revision (SEP-2567). - await WriteJsonRpcErrorAsync(context, - "Bad Request: Mcp-Session-Id is not supported by the 2026-07-28 and later protocol revisions (SEP-2567).", - StatusCodes.Status400BadRequest, requestId: requestId); - return null; - } - if (!HttpServerTransportOptions.Stateless) { // The author explicitly opted into sessions (Stateless = false), which the 2026-07-28 - // revision cannot provide. Refuse it so a dual-era client falls back to the legacy + // revision cannot provide. Refuse it so a dual-path client falls back to the // initialize handshake and gets the session it asked for (SEP-2575 fallback semantics). await WriteUnsupportedProtocolVersionErrorAsync(context, requestId); return null; @@ -402,6 +394,7 @@ await WriteJsonRpcErrorAsync(context, return await StartNewSessionAsync(context); } + var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString(); if (string.IsNullOrEmpty(sessionId)) { // In stateful mode, only allow creating new sessions for initialize requests. @@ -435,13 +428,13 @@ await WriteJsonRpcErrorAsync(context, /// /// Returns when the request's MCP-Protocol-Version header declares a /// revision that operates without sessions, so the server must serve it statelessly. Such requests - /// never carry an Mcp-Session-Id and never perform the legacy initialize handshake + /// do not use an Mcp-Session-Id and never perform the initialize handshake /// (SEP-2575 + SEP-2567). /// - private static bool IsJuly2026OrLaterProtocol(HttpContext context) + private static bool RequiresPerRequestMetadataProtocol(HttpContext context) { var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); - return McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(protocolVersionHeader); + return McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionHeader); } private async ValueTask StartNewSessionAsync(HttpContext context) @@ -665,11 +658,14 @@ internal static Task RunSessionAsync(HttpContext httpContext, McpServer session, /// rejection uses the error code with a data payload /// listing the server's supported versions so the client can select a fallback. /// - private static bool ValidateProtocolVersionHeader(HttpContext context, [NotNullWhen(false)] out JsonRpcErrorDetail? errorDetail) + private static bool ValidateProtocolVersionHeader( + HttpContext context, + IReadOnlyList supportedProtocolVersions, + [NotNullWhen(false)] out JsonRpcErrorDetail? errorDetail) { var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); if (!string.IsNullOrEmpty(protocolVersionHeader) && - !s_supportedProtocolVersions.Contains(protocolVersionHeader)) + !supportedProtocolVersions.Contains(protocolVersionHeader)) { errorDetail = new JsonRpcErrorDetail { @@ -678,7 +674,7 @@ private static bool ValidateProtocolVersionHeader(HttpContext context, [NotNullW Data = JsonSerializer.SerializeToNode( new UnsupportedProtocolVersionErrorData { - Supported = [.. s_supportedProtocolVersions], + Supported = [.. supportedProtocolVersions], Requested = protocolVersionHeader, }, GetRequiredJsonTypeInfo()), @@ -690,11 +686,108 @@ private static bool ValidateProtocolVersionHeader(HttpContext context, [NotNullW return true; } + private static string[] GetConfiguredSupportedProtocolVersions(string? protocolVersion) + { + if (protocolVersion is null) + { + return s_supportedProtocolVersions; + } + + if (!McpProtocolVersions.IsSupportedProtocolVersion(protocolVersion)) + { + throw new McpException( + $"Unsupported server protocol version '{protocolVersion}'. Supported protocol versions: " + + string.Join(", ", McpProtocolVersions.SupportedProtocolVersions) + "."); + } + + return [protocolVersion]; + } + + /// + /// Validates that HTTP requests using per-request metadata declare the same protocol version in both + /// the MCP-Protocol-Version header and body _meta envelope. + /// + private static bool ValidateProtocolVersionEnvelope( + HttpContext context, + JsonRpcMessage message, + [NotNullWhen(false)] out JsonRpcErrorDetail? errorDetail) + { + if (message is not (JsonRpcRequest or JsonRpcNotification)) + { + errorDetail = null; + return true; + } + + var protocolVersionHeader = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); + bool hasProtocolVersionMeta = TryGetProtocolVersionMeta(message, out var protocolVersionMeta); + + if (!McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionHeader) && + !McpProtocolVersions.RequiresPerRequestMetadata(protocolVersionMeta)) + { + errorDetail = null; + return true; + } + + if (string.IsNullOrEmpty(protocolVersionHeader)) + { + errorDetail = CreateHeaderMismatchError( + $"Bad Request: The {McpProtocolVersionHeaderName} header is required when the request body declares a per-request metadata protocol version."); + return false; + } + + if (!hasProtocolVersionMeta) + { + errorDetail = CreateHeaderMismatchError( + $"Bad Request: The body _meta/{MetaKeys.ProtocolVersion} field is required when the {McpProtocolVersionHeaderName} header declares a per-request metadata protocol version."); + return false; + } + + if (!string.Equals(protocolVersionHeader, protocolVersionMeta, StringComparison.Ordinal)) + { + errorDetail = CreateHeaderMismatchError( + $"Bad Request: The {McpProtocolVersionHeaderName} header value '{protocolVersionHeader}' does not match body _meta/{MetaKeys.ProtocolVersion} value '{protocolVersionMeta}'."); + return false; + } + + errorDetail = null; + return true; + } + + private static JsonRpcErrorDetail CreateHeaderMismatchError(string message) => new() + { + Code = (int)McpErrorCode.HeaderMismatch, + Message = message, + }; + + private static bool TryGetProtocolVersionMeta(JsonRpcMessage message, [NotNullWhen(true)] out string? protocolVersion) + { + var parameters = message switch + { + JsonRpcRequest request => request.Params, + JsonRpcNotification notification => notification.Params, + _ => null, + }; + + string? value = null; + if (parameters is JsonObject paramsObj && + paramsObj["_meta"] is JsonObject metaObj && + metaObj[MetaKeys.ProtocolVersion] is JsonValue protocolVersionValue && + protocolVersionValue.TryGetValue(out value) && + !string.IsNullOrEmpty(value)) + { + protocolVersion = value; + return true; + } + + protocolVersion = null; + return false; + } + /// /// Refuses a 2026-07-28 (or later) request on a stateful (Stateless = false) server. Starting with that /// revision, Streamable HTTP no longer has sessions (SEP-2567), so it cannot honor the author's opt-in to /// sessions; we return with a supported-versions list - /// that excludes 2026-07-28 and later. A dual-era client then falls back to the legacy initialize handshake + /// that excludes 2026-07-28 and later. A dual-path client then falls back to the initialize handshake /// (SEP-2575). /// private static Task WriteUnsupportedProtocolVersionErrorAsync(HttpContext context, RequestId requestId = default) @@ -703,7 +796,7 @@ private static Task WriteUnsupportedProtocolVersionErrorAsync(HttpContext contex var errorDetail = new JsonRpcErrorDetail { Code = (int)McpErrorCode.UnsupportedProtocolVersion, - Message = $"Bad Request: Starting with protocol version '{McpHttpHeaders.July2026ProtocolVersion}', Streamable HTTP does not support sessions and is not supported when the server is configured with sessions enabled (HttpServerTransportOptions.Stateless = false). " + + Message = $"Bad Request: Starting with protocol version '{McpProtocolVersions.July2026ProtocolVersion}', Streamable HTTP does not support sessions and is not supported when the server is configured with sessions enabled (HttpServerTransportOptions.Stateless = false). " + "Use the initialize handshake with a protocol version that still supports sessions instead.", Data = JsonSerializer.SerializeToNode( new UnsupportedProtocolVersionErrorData @@ -731,7 +824,7 @@ internal static bool ValidateMcpHeaders(HttpContext context, JsonRpcMessage mess { // Only validate for protocol versions that support standard headers. var protocolVersion = context.Request.Headers[McpProtocolVersionHeaderName].ToString(); - if (!McpHttpHeaders.SupportsStandardHeaders(protocolVersion)) + if (!McpProtocolVersions.RequiresStandardHeaders(protocolVersion)) { errorMessage = null; return true; diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index 39f6579e3..fb8c3dfa7 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -294,24 +294,20 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) // handshake. Instead, the client calls server/discover to learn the server's // capabilities and then begins sending normal RPCs that carry protocolVersion / // clientInfo / clientCapabilities in their per-request _meta. A null ProtocolVersion - // prefers the 2026-07-28 revision and automatically falls back to the legacy initialize - // handshake when the server doesn't support it. The legacy branch below runs only when + // prefers the 2026-07-28 revision and automatically falls back to the initialize + // handshake when the server doesn't support it. The initialize branch below runs only when // the caller explicitly pins a version that still supports Streamable HTTP sessions (opting out of the default). - if (_options.ProtocolVersion is null || McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(_options.ProtocolVersion)) + if (_options.ProtocolVersion is null || McpProtocolVersions.RequiresPerRequestMetadata(_options.ProtocolVersion)) { - string preferredVersion = _options.ProtocolVersion ?? McpHttpHeaders.July2026ProtocolVersion; - - // Eagerly set the negotiated version so InjectRequestMetaIfNeeded recognizes us as being - // on the 2026-07-28 revision when SendRequestAsync is invoked for server/discover. - _negotiatedProtocolVersion = preferredVersion; - _sessionHandler.NegotiatedProtocolVersion = preferredVersion; + string preferredVersion = _options.ProtocolVersion ?? McpProtocolVersions.July2026ProtocolVersion; DiscoverResult? discoverResult = null; - bool fallbackToLegacy = false; + bool fallbackToInitialize = false; IList? serverSupportedVersions = null; + string discoverVersion = preferredVersion; - // Apply a probe timeout so dual-era clients don't block forever waiting for a - // legacy server that silently drops unknown methods (per stdio.mdx fallback rules). + // Apply a probe timeout so dual-path clients don't block forever waiting for an + // initialize-handshake server that silently drops unknown methods (per stdio.mdx fallback rules). // The probe timeout is configurable via McpClientOptions.DiscoverProbeTimeout and is // always bounded by InitializationTimeout (only applied when it is the tighter bound). var probeTimeout = _options.DiscoverProbeTimeout; @@ -323,75 +319,101 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) try { - discoverResult = await SendRequestAsync( - RequestMethods.ServerDiscover, - new DiscoverRequestParams(), - McpJsonUtilities.JsonContext.Default.DiscoverRequestParams, - McpJsonUtilities.JsonContext.Default.DiscoverResult, - cancellationToken: probeCts.Token).ConfigureAwait(false); + discoverResult = await SendDiscoverAsync(discoverVersion, probeCts.Token).ConfigureAwait(false); } catch (UnsupportedProtocolVersionException ex) { - // Spec-recognized modern-server signal: -32022 with data.supported[]. The server is - // modern but doesn't speak our preferred version. Retry with a mutually supported - // version from data.supported[] instead of falling back to legacy initialize. - fallbackToLegacy = true; + // Spec-recognized SEP-2575 signal: -32022 with data.supported[]. The server is + // refusing our preferred version. Retry with a supported per-request metadata + // version if one exists; otherwise fall back to initialize with the highest + // mutually supported initialize-capable version. serverSupportedVersions = (IList)ex.Supported; + var retryVersion = serverSupportedVersions + .Where(McpProtocolVersions.PerRequestMetadataProtocolVersions.Contains) + .OrderByDescending(v => v, StringComparer.Ordinal) + .FirstOrDefault(); + + if (retryVersion is not null) + { + if (_options.ProtocolVersion is { } pinnedVersion && + StringComparer.Ordinal.Compare(retryVersion, pinnedVersion) < 0) + { + throw new McpException( + $"The server does not support the requested protocol version '{pinnedVersion}'. " + + "Leave McpClientOptions.ProtocolVersion unset to allow automatic fallback to an older version. " + + $"Server-supported versions: {string.Join(", ", serverSupportedVersions)}."); + } + + discoverVersion = retryVersion; + discoverResult = await SendDiscoverAsync(discoverVersion, probeCts.Token).ConfigureAwait(false); + } + else + { + fallbackToInitialize = true; + } } catch (MissingRequiredClientCapabilityException) { - // Spec-recognized modern-server signal: -32021. The server is modern but rejected + // Spec-recognized SEP-2575 signal: -32021. The server rejected // our capability set. Surface as-is (no fallback): the user must add capabilities. throw; } catch (McpProtocolException ex) when (ex.ErrorCode == McpErrorCode.HeaderMismatch) { - // Spec-recognized modern-server signal: -32020. The server is modern but rejected + // Spec-recognized SEP-2575 signal: -32020. The server rejected // our request envelope (e.g., the MCP-Protocol-Version HTTP header didn't match // the body _meta.io.modelcontextprotocol/protocolVersion). Surface as-is (no - // fallback): falling back to legacy initialize wouldn't fix a malformed envelope. + // fallback): falling back to initialize wouldn't fix a malformed envelope. + throw; + } + catch (McpProtocolException ex) when ( + ex.ErrorCode == McpErrorCode.InvalidRequest && + ex.Message.Contains(McpHttpHeaders.SessionId, StringComparison.Ordinal)) + { + // Local transport validation: a 2026-07-28+ response must not carry HTTP session state. + // This is not evidence of an initialize-handshake server, so do not fall back. throw; } catch (McpProtocolException) { // Per spec PR #2844, the fallback MUST NOT be keyed to a single error code. - // Any non-modern JSON-RPC error from the probe indicates a legacy server. + // Any non-SEP-2575 JSON-RPC error from the probe indicates an initialize-handshake server. // Common causes include MethodNotFound from a server that has no // server/discover handler, InvalidParams from a server confused by the // SEP-2575 _meta envelope, ParseError from a server that can't handle our - // payload shape, or any other transport-defined error. The three modern-server + // payload shape, or any other transport-defined error. The three SEP-2575 // signals (-32022 UnsupportedProtocolVersion, -32021 // MissingRequiredClientCapability, -32020 HeaderMismatch) are caught above and // never reach here. - fallbackToLegacy = true; + fallbackToInitialize = true; } catch (OperationCanceledException) when (probeCts.IsCancellationRequested && !initializationCts.IsCancellationRequested) { // Probe timeout elapsed without a response. Per stdio.mdx fallback rules, no - // response within a reasonable timeout means the server is legacy. Fall back. - fallbackToLegacy = true; + // response within a reasonable timeout means the server requires initialize. Fall back. + fallbackToInitialize = true; } - if (discoverResult is not null && !discoverResult.SupportedVersions.Contains(preferredVersion)) + if (discoverResult is not null && !discoverResult.SupportedVersions.Contains(discoverVersion)) { // Server is reachable and supports server/discover, but doesn't support the - // 2026-07-28 version. Fall back to legacy initialize with the highest - // mutually-supported version from supportedVersions[]. - fallbackToLegacy = true; + // version we are using. Fall back to initialize with the highest + // mutually-supported initialize-capable version from supportedVersions[]. + fallbackToInitialize = true; serverSupportedVersions = discoverResult.SupportedVersions; } - if (fallbackToLegacy) + if (fallbackToInitialize) { - // Reset negotiated state and try legacy initialize. + // Reset negotiated state and try initialize. _negotiatedProtocolVersion = null; _sessionHandler.NegotiatedProtocolVersion = null; string fallbackVersion = serverSupportedVersions? - .Where(McpSessionHandler.SupportedProtocolVersions.Contains) + .Where(McpProtocolVersions.InitializeHandshakeProtocolVersions.Contains) .OrderByDescending(v => v, StringComparer.Ordinal) .FirstOrDefault() - ?? McpHttpHeaders.November2025ProtocolVersion; + ?? McpProtocolVersions.November2025ProtocolVersion; // A non-null ProtocolVersion is also the minimum: refuse to fall back below the // explicitly requested version. String.Compare is the spec's prescribed ordering @@ -403,11 +425,11 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) $"The server does not support the requested protocol version '{pinnedVersion}'. " + "Leave McpClientOptions.ProtocolVersion unset to allow automatic fallback to an older version. " + (serverSupportedVersions is null - ? "The server appears to be a legacy server that requires the deprecated initialize handshake." + ? "The server appears to require the initialize handshake." : $"Server-supported versions: {string.Join(", ", serverSupportedVersions)}.")); } - await PerformLegacyInitializeAsync(fallbackVersion, initializationCts.Token).ConfigureAwait(false); + await PerformInitializeHandshakeAsync(fallbackVersion, initializationCts.Token).ConfigureAwait(false); } else { @@ -422,14 +444,29 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) _serverInfo = discoverResult.ServerInfo; _serverInstructions = discoverResult.Instructions; } + + async Task SendDiscoverAsync(string protocolVersion, CancellationToken cancellationToken) + { + // Eagerly set the negotiated version so InjectRequestMetaIfNeeded recognizes us as being + // on a per-request metadata revision when SendRequestAsync is invoked for server/discover. + _negotiatedProtocolVersion = protocolVersion; + _sessionHandler.NegotiatedProtocolVersion = protocolVersion; + + return await SendRequestAsync( + RequestMethods.ServerDiscover, + new DiscoverRequestParams(), + McpJsonUtilities.JsonContext.Default.DiscoverRequestParams, + McpJsonUtilities.JsonContext.Default.DiscoverResult, + cancellationToken: cancellationToken).ConfigureAwait(false); + } } else { - // Legacy initialize handshake. Reached only when the caller explicitly pinned a + // initialize handshake. Reached only when the caller explicitly pinned a // ProtocolVersion that still supports Streamable HTTP sessions (opting out of the default), so // _options.ProtocolVersion is non-null here. - string requestProtocol = _options.ProtocolVersion ?? McpHttpHeaders.November2025ProtocolVersion; - await PerformLegacyInitializeAsync(requestProtocol, initializationCts.Token).ConfigureAwait(false); + string requestProtocol = _options.ProtocolVersion ?? McpProtocolVersions.November2025ProtocolVersion; + await PerformInitializeHandshakeAsync(requestProtocol, initializationCts.Token).ConfigureAwait(false); } } catch (OperationCanceledException oce) when (initializationCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) @@ -449,10 +486,10 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) } /// - /// Performs the legacy initialize handshake (initialize request + initialized notification), + /// Performs the initialize handshake (initialize request + initialized notification), /// records the negotiated protocol version, and stores the server capabilities/info/instructions. /// - private async Task PerformLegacyInitializeAsync(string requestProtocol, CancellationToken cancellationToken) + private async Task PerformInitializeHandshakeAsync(string requestProtocol, CancellationToken cancellationToken) { var initializeResponse = await SendRequestAsync( RequestMethods.Initialize, @@ -479,18 +516,16 @@ private async Task PerformLegacyInitializeAsync(string requestProtocol, Cancella _serverInstructions = initializeResponse.Instructions; // When the user explicitly pinned a version that supports Streamable HTTP sessions, the server MUST respect it. - // When the user pinned the 2026-07-28 version but we fell back (e.g., legacy server rejected - // server/discover), or when no version was pinned, accept any supported response. This is the - // spec-mandated behavior: a 2026-07-28 client must be able to downgrade to whatever - // version the server advertises. + // When no version was pinned, accept any supported initialize-handshake response. initialize cannot negotiate + // the 2026-07-28 and later protocol revisions. bool isResponseProtocolValid; - if (_options.ProtocolVersion is { } optionsProtocol && !McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(optionsProtocol)) + if (_options.ProtocolVersion is { } optionsProtocol && !McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(optionsProtocol)) { isResponseProtocolValid = optionsProtocol == initializeResponse.ProtocolVersion; } else { - isResponseProtocolValid = McpSessionHandler.SupportedProtocolVersions.Contains(initializeResponse.ProtocolVersion); + isResponseProtocolValid = McpProtocolVersions.InitializeHandshakeProtocolVersions.Contains(initializeResponse.ProtocolVersion); } if (!isResponseProtocolValid) { @@ -525,7 +560,7 @@ internal void ResumeSession(ResumeClientSessionOptions resumeOptions) _serverInstructions = resumeOptions.ServerInstructions; _negotiatedProtocolVersion = resumeOptions.NegotiatedProtocolVersion ?? _options.ProtocolVersion - ?? McpHttpHeaders.November2025ProtocolVersion; + ?? McpProtocolVersions.November2025ProtocolVersion; // Update session handler with the negotiated protocol version for telemetry _sessionHandler.NegotiatedProtocolVersion = _negotiatedProtocolVersion; @@ -691,7 +726,7 @@ request.Params is System.Text.Json.Nodes.JsonObject paramsObjForHeaders && /// /// Injects the 2026-07-28 protocol's per-request _meta fields (protocol version, client info, /// client capabilities) into the request when this client negotiated the 2026-07-28 or later revision - /// (SEP-2575). No-op on a legacy session. + /// (SEP-2575). No-op on an initialize-handshake session. /// private void InjectRequestMetaIfNeeded(JsonRpcRequest request) { diff --git a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs index aaf3cefa6..74736d36f 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs @@ -52,16 +52,20 @@ public sealed class McpClientOptions /// /// /// + /// Supported values are 2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25, + /// and 2026-07-28. + /// + /// /// When (the default), the client prefers the latest revision (2026-07-28), /// which removed the initialize handshake and Streamable HTTP sessions. It probes with - /// server/discover and automatically falls back to the legacy initialize handshake, - /// downgrading to any version the server advertises, when the server does not support that revision. + /// server/discover and automatically falls back to the initialize handshake, + /// downgrading to an initialize-capable version the server advertises, when the server does not support that revision. /// /// /// When non-, this value is both the requested version and the minimum the client /// will accept: the client requests exactly this version and refuses to downgrade below it, throwing an /// instead of falling back. Setting it to 2026-07-28 therefore disables - /// the automatic legacy-server fallback, and setting it to a version that still supports Streamable HTTP + /// the automatic initialize-handshake server fallback, and setting it to a version that still supports Streamable HTTP /// sessions, such as 2025-11-25, forces the initialize handshake and fails if the server /// negotiates a different version. To try more than one version, leave this unset for automatic fallback /// or retry the connection with a different value. @@ -90,7 +94,7 @@ public sealed class McpClientOptions /// /// Gets or sets the timeout applied to the server/discover probe that the client issues - /// before falling back to the legacy initialize handshake. + /// before falling back to the initialize handshake. /// /// /// The probe timeout. The default value is 5 seconds. Use @@ -102,17 +106,17 @@ public sealed class McpClientOptions /// This timeout only has an effect when the client prefers the 2026-07-28 protocol revision, that is, /// when is (the default) or 2026-07-28. /// In that mode the client first probes the server with a - /// server/discover request. A legacy server that predates the 2026-07-28 revision may + /// server/discover request. A server that predates the 2026-07-28 revision may /// silently drop the unknown method, so the probe is bounded by this timeout; when it elapses the - /// client concludes the server is legacy and falls back to the initialize handshake on the - /// same connection. When the caller pins a legacy , no probe is issued + /// client concludes the server requires initialize and falls back to that handshake on the + /// same connection. When the caller pins an initialize-capable , no probe is issued /// and this value has no effect. /// /// - /// The default is intentionally short so that dual-era clients fall back quickly against legacy + /// The default is intentionally short so that dual-path clients fall back quickly against initialize-handshake /// servers. Increase it for high-latency environments (for example, cold-start serverless peers or - /// satellite links) where a short probe could trigger the legacy fallback before a server on the new revision - /// server has had a chance to respond. The probe is always also bounded by + /// satellite links) where a short probe could trigger the initialize fallback before a server on the + /// per-request metadata revision has had a chance to respond. The probe is always also bounded by /// , which governs the overall connect budget: if this value is /// greater than or equal to , the probe is effectively bounded by /// alone. diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs index 26ffdce65..1bfb68f43 100644 --- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs @@ -67,19 +67,19 @@ public override async Task SendMessageAsync(JsonRpcMessage message, Cancellation // Per spec PR #2844 (HTTP backwards compatibility), a 400 Bad Request that carries a // JSON-RPC error envelope means the peer is signalling something application-level about // our request. Surface ANY JSON-RPC error on a 400 as McpProtocolException so the - // connect-time logic can react. For example, the three modern protocol error codes + // connect-time logic can react. For example, the three per-request metadata protocol error codes // (-32022 UnsupportedProtocolVersion, -32021 MissingRequiredClientCapability, // -32020 HeaderMismatch) lead to typed exceptions, while other codes (e.g. -32600 from - // legacy servers that don't understand the SEP-2575 _meta envelope) become generic - // McpProtocolException instances and trigger the fallback-to-legacy-initialize path. + // initialize-handshake servers that don't understand the SEP-2575 _meta envelope) become generic + // McpProtocolException instances and trigger the initialize-handshake fallback path. // Other status codes (401 auth, 403 forbidden, 404 session-not-found, 5xx server) continue // to surface as HttpRequestException to preserve back-compat with transport-layer behaviors. - // The three modern protocol error codes are also surfaced for non-400 status codes + // The three per-request metadata protocol error codes are also surfaced for non-400 status codes // for robustness. Servers occasionally emit them with 4xx codes other than 400. if (!response.IsSuccessStatusCode && await TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false) is { } parsedError && (response.StatusCode == HttpStatusCode.BadRequest || - IsModernProtocolErrorCode((McpErrorCode)parsedError.Error.Code))) + IsPerRequestMetadataProtocolErrorCode((McpErrorCode)parsedError.Error.Code))) { throw McpSessionHandler.CreateRemoteProtocolExceptionFromError(parsedError); } @@ -87,7 +87,7 @@ await TryReadJsonRpcErrorAsync(response, cancellationToken).ConfigureAwait(false await response.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false); } - private static bool IsModernProtocolErrorCode(McpErrorCode code) => + private static bool IsPerRequestMetadataProtocolErrorCode(McpErrorCode code) => code is McpErrorCode.UnsupportedProtocolVersion or McpErrorCode.MissingRequiredClientCapability or McpErrorCode.HeaderMismatch; @@ -217,7 +217,7 @@ internal async Task SendHttpRequestAsync(JsonRpcMessage mes if (rpcRequest.Method == RequestMethods.Initialize && rpcResponseOrError is JsonRpcResponse initResponse) { // We've successfully initialized! Copy session-id and protocol version, then start GET request if any. - if (response.Headers.TryGetValues("Mcp-Session-Id", out var sessionIdValues)) + if (response.Headers.TryGetValues(McpHttpHeaders.SessionId, out var sessionIdValues)) { SessionId = sessionIdValues.FirstOrDefault(); } @@ -528,7 +528,7 @@ internal static void CopyAdditionalHeaders( string? protocolVersion, string? lastEventId = null) { - if (sessionId is not null) + if (sessionId is not null && McpProtocolVersions.SupportsHttpSessions(protocolVersion)) { headers.Add(McpHttpHeaders.SessionId, sessionId); } diff --git a/src/ModelContextProtocol.Core/McpSession.cs b/src/ModelContextProtocol.Core/McpSession.cs index e1bd0844b..1747a6303 100644 --- a/src/ModelContextProtocol.Core/McpSession.cs +++ b/src/ModelContextProtocol.Core/McpSession.cs @@ -55,7 +55,7 @@ public abstract partial class McpSession : IAsyncDisposable /// definition of "is this peer speaking the 2026-07-28 or later revision" used by both the client and server. /// internal bool IsJuly2026OrLaterProtocol() => - McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(NegotiatedProtocolVersion); + McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(NegotiatedProtocolVersion); /// /// Sends a JSON-RPC request to the connected session and waits for a response. diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index a0122c9a4..61a1872f2 100644 --- a/src/ModelContextProtocol.Core/McpSessionHandler.cs +++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs @@ -29,18 +29,10 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable "mcp.server.operation.duration", "MCP request or notification duration as observed on the receiver from the time it was received until the result or ack is sent."); /// - /// All protocol versions supported by this implementation. The version constants live on + /// All protocol versions supported by this implementation. The era-specific lists live on /// so the shared source file is the single source of truth. - /// Keep in sync with s_supportedProtocolVersions in StreamableHttpHandler. /// - internal static readonly string[] SupportedProtocolVersions = - [ - "2024-11-05", - "2025-03-26", - "2025-06-18", - McpHttpHeaders.November2025ProtocolVersion, - McpHttpHeaders.July2026ProtocolVersion, - ]; + internal static readonly string[] SupportedProtocolVersions = McpProtocolVersions.SupportedProtocolVersions; /// /// Checks if the given protocol version supports priming events. @@ -53,7 +45,7 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable /// internal static bool SupportsPrimingEvent(string? protocolVersion) { - const string MinResumabilityProtocolVersion = McpHttpHeaders.November2025ProtocolVersion; + const string MinResumabilityProtocolVersion = McpProtocolVersions.November2025ProtocolVersion; if (protocolVersion is null) { @@ -81,7 +73,7 @@ internal static bool SupportsNaturalOutputSchemas(string? protocolVersion) return false; } - return string.Compare(protocolVersion, McpHttpHeaders.July2026ProtocolVersion, StringComparison.Ordinal) >= 0; + return string.Compare(protocolVersion, McpProtocolVersions.July2026ProtocolVersion, StringComparison.Ordinal) >= 0; } private readonly bool _isServer; @@ -162,7 +154,7 @@ public McpSessionHandler( (request, jsonRpcRequest, cancellationToken) => { string? perRequestVersion = jsonRpcRequest?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion; - if (McpHttpHeaders.IsJuly2026OrLaterProtocolVersion(perRequestVersion)) + if (McpProtocolVersions.RequiresPerRequestMetadata(perRequestVersion)) { throw new McpProtocolException( $"Method '{RequestMethods.Ping}' is not available on protocol version '{perRequestVersion}'.", @@ -583,8 +575,8 @@ internal static void PopulateContextFromMeta(JsonRpcRequest request) // If a transport-level header (e.g., the Streamable HTTP MCP-Protocol-Version header) already // populated this, validate the body _meta matches per SEP-2575. A disagreement is reported with // -32020 HeaderMismatch (the same code used for the Mcp-Method/Mcp-Name header-vs-body checks), - // which conformant 2026-07-28 clients recognize as a modern-server signal and surface as-is rather - // than mistaking it for a legacy server and falling back to the initialize handshake. + // which conformant 2026-07-28 clients recognize as a SEP-2575 signal and surface as-is rather + // than mistaking it for an initialize-handshake server and falling back to initialize. if (context.ProtocolVersion is { } existing && !string.Equals(existing, protocolVersionValue, StringComparison.Ordinal)) { throw new McpProtocolException( @@ -618,7 +610,7 @@ internal static void PopulateContextFromMeta(JsonRpcRequest request) /// /// /// Used by on a 2026-07-28 or later session to carry protocol version, client - /// info, and client capabilities on every outgoing request (replacing what the legacy + /// info, and client capabilities on every outgoing request (replacing what the /// initialize handshake previously negotiated once). /// internal static void InjectRequestMeta( diff --git a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj index 455c36798..ee9f24f3c 100644 --- a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj +++ b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj @@ -32,6 +32,7 @@ + diff --git a/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs b/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs index 5ec348c06..6ab6cedf6 100644 --- a/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs @@ -8,16 +8,19 @@ namespace ModelContextProtocol.Protocol; /// /// /// Introduced by the 2026-07-28 protocol revision (SEP-2575) as the canonical way for a client -/// to learn what a server supports without performing the legacy initialize handshake. +/// to learn what a server supports without performing the initialize handshake. /// /// public sealed class DiscoverResult : Result, ICacheableResult { /// - /// Gets or sets the list of MCP protocol version strings that the server supports. + /// Gets or sets the list of MCP protocol version strings the server supports for subsequent + /// per-request metadata requests. /// /// - /// The client should choose a version from this list for use in subsequent requests. + /// The client should choose a version from this list for subsequent requests that carry the + /// 2026-07-28-style per-request _meta envelope. Versions that require the + /// initialize handshake are negotiated through initialize instead. /// [JsonPropertyName("supportedVersions")] public required IList SupportedVersions { get; set; } diff --git a/src/ModelContextProtocol.Core/Protocol/InitializeRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/InitializeRequestParams.cs index 468754e96..63b17cdfe 100644 --- a/src/ModelContextProtocol.Core/Protocol/InitializeRequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/InitializeRequestParams.cs @@ -22,16 +22,17 @@ namespace ModelContextProtocol.Protocol; public sealed class InitializeRequestParams : RequestParams { /// - /// Gets or sets the version of the Model Context Protocol that the client wants to use. + /// Gets or sets the initialize-handshake Model Context Protocol version that the client wants to use. /// /// /// /// Protocol version is specified using a date-based versioning scheme in the format "YYYY-MM-DD". - /// The client and server must agree on a protocol version to communicate successfully. + /// The client and server must agree on an initialize-capable protocol version to communicate successfully. /// /// - /// During initialization, the server will check if it supports this requested version. If there's a - /// mismatch, the server will reject the connection with a version mismatch error. + /// During initialization, the server will check if it supports this requested version. Protocol + /// revisions starting with 2026-07-28 do not use initialize; clients select them with + /// server/discover and per-request metadata instead. /// /// /// See the protocol specification for version details. diff --git a/src/ModelContextProtocol.Core/Protocol/InitializeResult.cs b/src/ModelContextProtocol.Core/Protocol/InitializeResult.cs index e79113687..4c0f014f0 100644 --- a/src/ModelContextProtocol.Core/Protocol/InitializeResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/InitializeResult.cs @@ -22,11 +22,11 @@ namespace ModelContextProtocol.Protocol; public sealed class InitializeResult : Result { /// - /// Gets or sets the version of the Model Context Protocol that the server will use for this session. + /// Gets or sets the initialize-handshake Model Context Protocol version that the server will use for this session. /// /// /// - /// This is the protocol version the server has agreed to use, which should match the client's + /// This is the initialize-capable protocol version the server has agreed to use, which should match the client's /// requested version. If there's a mismatch, the client should throw an exception to prevent /// communication issues due to incompatible protocol versions. /// diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs index 646dac75e..0daeb010a 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessage.cs @@ -217,7 +217,7 @@ public sealed class Converter : JsonConverter // Per JSON-RPC 2.0, when an error occurs before the request id can be determined // (e.g. parse error or invalid request), the server MUST respond with id=null. // Accept null-id error responses so callers can recognize the structured signal - // (e.g. an HTTP 400 body whose JSON-RPC envelope carries a non-modern error code). + // (e.g. an HTTP 400 body whose JSON-RPC envelope carries a non-SEP-2575 error code). return new JsonRpcError { Id = id, diff --git a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs index a2884efba..29ba57626 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestMethods.cs @@ -159,15 +159,15 @@ public static class RequestMethods /// /// /// This RPC is introduced in the 2026-07-28 protocol revision (SEP-2575) as the canonical way for a client - /// to learn what a server supports without performing the legacy initialize handshake. + /// to learn what a server supports without performing the initialize handshake. /// /// /// The server's response includes its supported protocol versions, capabilities, implementation /// information, and optional usage instructions. /// /// - /// Servers SHOULD implement this method. Legacy clients MAY ignore it. Clients on the 2026-07-28 revision - /// typically call this once during connection establishment. + /// Servers SHOULD implement this method. Initialize-handshake clients MAY ignore it. Clients on the + /// 2026-07-28 revision typically call this once during connection establishment. /// /// public const string ServerDiscover = "server/discover"; @@ -179,7 +179,7 @@ public static class RequestMethods /// /// /// This RPC is introduced in the 2026-07-28 protocol revision (SEP-2575) and replaces the unsolicited - /// HTTP GET endpoint and the legacy / + /// HTTP GET endpoint and the initialize-handshake / /// request methods. /// /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index d98a6c7c8..935088fc0 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -27,11 +27,22 @@ internal sealed partial class McpServerImpl : McpServer private readonly NotificationHandlers _notificationHandlers; private readonly RequestHandlers _requestHandlers; private readonly McpSessionHandler _sessionHandler; + private readonly string[] _supportedProtocolVersions; + private readonly string[] _initializeHandshakeProtocolVersions; + private readonly string[] _perRequestMetadataProtocolVersions; private readonly SemaphoreSlim _disposeLock = new(1, 1); private readonly ConcurrentDictionary _taskCancellationSources = new(); private readonly ConcurrentDictionary _mrtrContinuations = new(); private readonly ConcurrentDictionary _mrtrContextsByRequestId = new(); + private static readonly string[] s_perRequestMetadataKeys = + [ + MetaKeys.ProtocolVersion, + MetaKeys.ClientInfo, + MetaKeys.ClientCapabilities, + MetaKeys.LogLevel, + ]; + // Track MRTR handler tasks using the same inFlightCount + TCS pattern as // McpSessionHandler.ProcessMessagesCoreAsync. Starts at 1 for DisposeAsync itself. private int _mrtrInFlightCount = 1; @@ -72,6 +83,9 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact _sessionTransport = transport; ServerOptions = options; Services = serviceProvider; + _supportedProtocolVersions = GetConfiguredSupportedProtocolVersions(options.ProtocolVersion); + _initializeHandshakeProtocolVersions = [.. _supportedProtocolVersions.Where(McpProtocolVersions.SupportsInitializeHandshake)]; + _perRequestMetadataProtocolVersions = [.. _supportedProtocolVersions.Where(McpProtocolVersions.RequiresPerRequestMetadata)]; _serverOnlyEndpointName = $"Server ({options.ServerInfo?.Name ?? DefaultImplementation.Name} {options.ServerInfo?.Version ?? DefaultImplementation.Version})"; _endpointName = _serverOnlyEndpointName; _servicesScopePerRequest = options.ScopeRequests; @@ -161,33 +175,93 @@ void Register(McpServerPrimitiveCollection? collection, /// MUST be populated per-request. Per-request client capabilities and client info are consumed request-scoped /// by and are not read from server-wide state by request handlers. The /// shared write below is best-effort and used only to derive the session endpoint - /// name for logging/telemetry. For legacy clients the per-request values are absent and the built-in filter is - /// a no-op (the values were captured during the initialize handler). + /// name for logging/telemetry. For initialize-handshake clients the per-request values are absent and the built-in + /// filter is a no-op (the values were captured during the initialize handler). /// private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner) { JsonRpcMessageFilter metaReadingFilter = next => async (message, cancellationToken) => { - if (message is JsonRpcRequest { Method: not RequestMethods.Initialize } request && request.Context is { } context) + if (message is JsonRpcRequest { Method: RequestMethods.Initialize } initializeRequest) + { + ValidateInitializeRequestBoundary(initializeRequest); + } + else if (message is JsonRpcRequest request) { + var context = request.Context; bool endpointNameNeedsRefresh = false; + bool hasProtocolVersionMeta = HasMetaKey(request, MetaKeys.ProtocolVersion); + bool hasReservedPerRequestMeta = TryGetPerRequestMetadataKey(request, out var reservedPerRequestMetaKey); - if (context.ProtocolVersion is { } protocolVersion) + if (context?.ProtocolVersion is { } protocolVersion) { + bool protocolVersionAlreadyEstablished = _negotiatedProtocolVersion is not null; + if (protocolVersionAlreadyEstablished) + { + SetNegotiatedProtocolVersion(protocolVersion); + } + // Per SEP-2575, the server MUST reject any request whose per-request // _meta/io.modelcontextprotocol/protocolVersion is not one of its supported versions // with an UnsupportedProtocolVersionError (-32022) carrying the supported list. - if (!McpSessionHandler.SupportedProtocolVersions.Contains(protocolVersion)) + if (!_supportedProtocolVersions.Contains(protocolVersion)) { throw new UnsupportedProtocolVersionException( requested: protocolVersion, - supported: McpSessionHandler.SupportedProtocolVersions); + supported: _supportedProtocolVersions); } - SetNegotiatedProtocolVersion(protocolVersion); + if (McpProtocolVersions.RequiresPerRequestMetadata(protocolVersion)) + { + ValidateRequiredPerRequestMetadata( + protocolVersion, + hasProtocolVersionMeta, + context.ClientInfo is not null, + context.ClientCapabilities is not null); + } + else if (McpProtocolVersions.SupportsInitializeHandshake(protocolVersion)) + { + if (_negotiatedProtocolVersion is null && hasProtocolVersionMeta) + { + throw new UnsupportedProtocolVersionException( + requested: protocolVersion, + supported: _perRequestMetadataProtocolVersions, + message: $"Protocol version '{protocolVersion}' requires the initialize handshake and cannot be selected through per-request metadata."); + } + + if (hasReservedPerRequestMeta) + { + ThrowReservedPerRequestMetadata(requestedProtocolVersion: protocolVersion, reservedPerRequestMetaKey); + } + } + + if (!protocolVersionAlreadyEstablished) + { + SetNegotiatedProtocolVersion(protocolVersion); + } + } + else if (_negotiatedProtocolVersion is null) + { + if (request.Method == RequestMethods.ServerDiscover) + { + throw new McpProtocolException( + $"The '{RequestMethods.ServerDiscover}' request requires per-request metadata declaring a supported protocol version.", + McpErrorCode.InvalidParams); + } + + if (hasReservedPerRequestMeta) + { + ThrowReservedPerRequestMetadata(requestedProtocolVersion: null, reservedPerRequestMetaKey); + } } + else if (McpProtocolVersions.SupportsInitializeHandshake(_negotiatedProtocolVersion) && hasReservedPerRequestMeta) + { + ThrowReservedPerRequestMetadata(_negotiatedProtocolVersion, reservedPerRequestMetaKey); + } + + ValidateRequestMethodBoundary(request); - if (context.ClientInfo is { } clientInfo && + if (context?.ClientInfo is { } clientInfo && (_clientInfo is null || !string.Equals(_clientInfo.Name, clientInfo.Name, StringComparison.Ordinal) || !string.Equals(_clientInfo.Version, clientInfo.Version, StringComparison.Ordinal))) { @@ -207,6 +281,10 @@ private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner _sessionHandler.EndpointName = _endpointName; } } + else if (message is JsonRpcNotification notification) + { + ValidateNotificationBoundary(notification); + } await next(message, cancellationToken).ConfigureAwait(false); }; @@ -214,6 +292,142 @@ private JsonRpcMessageFilter PrependMetaReadingFilter(JsonRpcMessageFilter inner return next => metaReadingFilter(inner(next)); } + private static void ValidateRequiredPerRequestMetadata( + string protocolVersion, + bool hasProtocolVersionMeta, + bool hasClientInfoMeta, + bool hasClientCapabilitiesMeta) + { + if (!hasProtocolVersionMeta) + { + ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ProtocolVersion); + } + + if (!hasClientInfoMeta) + { + ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ClientInfo); + } + + if (!hasClientCapabilitiesMeta) + { + ThrowMissingPerRequestMetadata(protocolVersion, MetaKeys.ClientCapabilities); + } + } + + private static void ThrowMissingPerRequestMetadata(string protocolVersion, string key) => + throw new McpProtocolException( + $"Requests using protocol version '{protocolVersion}' must include '_meta/{key}'.", + McpErrorCode.InvalidParams); + + private static void ThrowReservedPerRequestMetadata(string? requestedProtocolVersion, string key) => + throw new McpProtocolException( + requestedProtocolVersion is null + ? $"The reserved per-request metadata key '_meta/{key}' requires a protocol version that uses per-request metadata." + : $"The reserved per-request metadata key '_meta/{key}' is not valid with protocol version '{requestedProtocolVersion}'.", + McpErrorCode.InvalidRequest); + + private static bool TryGetPerRequestMetadataKey(JsonRpcRequest request, out string key) + { + foreach (var candidate in s_perRequestMetadataKeys) + { + if (HasMetaKey(request, candidate)) + { + key = candidate; + return true; + } + } + + key = ""; + return false; + } + + private static bool HasMetaKey(JsonRpcRequest request, string key) => + request.Params is JsonObject paramsObj && + paramsObj["_meta"] is JsonObject metaObj && + metaObj.ContainsKey(key); + + private void ValidateInitializeRequestBoundary(JsonRpcRequest request) + { + if (request.Context?.ProtocolVersion is { } protocolVersion && + !McpProtocolVersions.SupportsInitializeHandshake(protocolVersion)) + { + throw new UnsupportedProtocolVersionException( + requested: protocolVersion, + supported: _initializeHandshakeProtocolVersions, + message: $"Protocol version '{protocolVersion}' is not available through the initialize handshake."); + } + + if (TryGetPerRequestMetadataKey(request, out var key)) + { + ThrowReservedPerRequestMetadata(TryGetStringParam(request, "protocolVersion"), key); + } + } + + private static string? TryGetStringParam(JsonRpcRequest request, string propertyName) + { + if (request.Params is JsonObject paramsObj && + paramsObj[propertyName] is JsonValue value && + value.TryGetValue(out string? result)) + { + return result; + } + + return null; + } + + private static string[] GetConfiguredSupportedProtocolVersions(string? protocolVersion) + { + if (protocolVersion is null) + { + return McpProtocolVersions.SupportedProtocolVersions; + } + + if (!McpProtocolVersions.IsSupportedProtocolVersion(protocolVersion)) + { + throw new McpException( + $"Unsupported server protocol version '{protocolVersion}'. Supported protocol versions: " + + string.Join(", ", McpProtocolVersions.SupportedProtocolVersions) + "."); + } + + return [protocolVersion]; + } + + private void ValidateNotificationBoundary(JsonRpcNotification notification) + { + if (notification.Method == NotificationMethods.InitializedNotification && + McpProtocolVersions.RequiresPerRequestMetadata(notification.Context?.ProtocolVersion ?? _negotiatedProtocolVersion)) + { + throw new McpProtocolException( + $"The notification '{NotificationMethods.InitializedNotification}' is only valid after the initialize handshake.", + McpErrorCode.InvalidRequest); + } + } + + private void ValidateRequestMethodBoundary(JsonRpcRequest request) + { + bool usesPerRequestMetadata = IsJuly2026OrLaterProtocolRequest(request); + + if (!usesPerRequestMetadata && + request.Method is RequestMethods.SubscriptionsListen + or RequestMethods.TasksGet + or RequestMethods.TasksUpdate + or RequestMethods.TasksCancel + or RequestMethods.ServerDiscover) + { + throw new McpProtocolException( + $"The method '{request.Method}' requires a newer protocol revision that supports per-request metadata; " + + $"the negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'.", + McpErrorCode.MethodNotFound); + } + + if (usesPerRequestMetadata && request.Method == RequestMethods.LoggingSetLevel) + { + throw new McpProtocolException( + $"The method '{RequestMethods.LoggingSetLevel}' is not available on protocol version '{request.Context?.ProtocolVersion ?? NegotiatedProtocolVersion}'. Use per-request _meta/{MetaKeys.LogLevel} instead.", + McpErrorCode.MethodNotFound); + } + } + /// public override string? SessionId => _sessionTransport.SessionId; @@ -356,26 +570,54 @@ private void ConfigureInitialize(McpServerOptions options) 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. + // Negotiate an initialize-handshake protocol version. initialize is not available in the 2026-07-28 + // and later protocol revisions, so those versions must use server/discover with + // per-request _meta instead. string? protocolVersion = options.ProtocolVersion; - protocolVersion ??= request?.ProtocolVersion is string clientProtocolVersion && - McpSessionHandler.SupportedProtocolVersions.Contains(clientProtocolVersion) ? - clientProtocolVersion : - McpHttpHeaders.November2025ProtocolVersion; + if (protocolVersion is { } configuredProtocolVersion && + McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(configuredProtocolVersion)) + { + throw new UnsupportedProtocolVersionException( + configuredProtocolVersion, + _initializeHandshakeProtocolVersions, + $"Protocol version '{configuredProtocolVersion}' is not available through the initialize handshake."); + } + + if (protocolVersion is null) + { + if (request?.ProtocolVersion is string clientProtocolVersion) + { + if (McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(clientProtocolVersion)) + { + throw new UnsupportedProtocolVersionException( + clientProtocolVersion, + _initializeHandshakeProtocolVersions, + $"Protocol version '{clientProtocolVersion}' is not available through the initialize handshake."); + } + + protocolVersion = McpProtocolVersions.SupportsInitializeHandshake(clientProtocolVersion) ? + clientProtocolVersion : + McpProtocolVersions.November2025ProtocolVersion; + } + else + { + protocolVersion = McpProtocolVersions.November2025ProtocolVersion; + } + } - // The legacy initialize handshake is authoritative: it may supersede a protocol version - // a prior server/discover probe established on the same connection (the dual-era + string negotiatedProtocolVersion = protocolVersion ?? McpProtocolVersions.November2025ProtocolVersion; + + // The initialize handshake is authoritative: it may supersede a protocol version + // a prior server/discover probe established on the same connection (the dual-path // fallback path a permissive client takes against an unknown server). Unlike the // per-request 2026-07-28 version - which SetNegotiatedProtocolVersion locks once negotiated - // initialize force-sets the version. - _negotiatedProtocolVersion = protocolVersion; - _sessionHandler.NegotiatedProtocolVersion = protocolVersion; + _negotiatedProtocolVersion = negotiatedProtocolVersion; + _sessionHandler.NegotiatedProtocolVersion = negotiatedProtocolVersion; return new InitializeResult { - ProtocolVersion = protocolVersion, + ProtocolVersion = negotiatedProtocolVersion, Instructions = options.ServerInstructions, ServerInfo = options.ServerInfo ?? DefaultImplementation, Capabilities = ServerCapabilities ?? new(), @@ -389,9 +631,9 @@ private void ConfigureInitialize(McpServerOptions options) /// Registers the server/discover request handler introduced by the 2026-07-28 protocol revision (SEP-2575). /// /// - /// The handler is registered unconditionally so legacy clients can probe it too. It returns the server's - /// supported protocol versions (), server - /// capabilities, server info, and optional instructions. + /// The handler is registered unconditionally so requests can be routed to the protocol boundary filters. Successful + /// server/discover responses advertise only protocol versions available through per-request metadata; versions + /// that require the initialize handshake are negotiated through initialize instead. /// private void ConfigureDiscover(McpServerOptions options) { @@ -400,7 +642,7 @@ private void ConfigureDiscover(McpServerOptions options) { return new ValueTask(new DiscoverResult { - SupportedVersions = [.. McpSessionHandler.SupportedProtocolVersions], + SupportedVersions = [.. _perRequestMetadataProtocolVersions], Capabilities = ServerCapabilities ?? new(), ServerInfo = options.ServerInfo ?? DefaultImplementation, Instructions = options.ServerInstructions, @@ -435,6 +677,14 @@ private void ConfigureSubscriptions(McpServerOptions options) _requestHandlers.Set(RequestMethods.SubscriptionsListen, async (request, jsonRpcRequest, cancellationToken) => { + if (!IsJuly2026OrLaterProtocolRequest(jsonRpcRequest)) + { + throw new McpProtocolException( + $"The method '{RequestMethods.SubscriptionsListen}' requires a newer protocol revision that supports per-request subscriptions; " + + $"the negotiated protocol version is '{NegotiatedProtocolVersion ?? "(none)"}'.", + McpErrorCode.MethodNotFound); + } + var requested = request?.Notifications ?? new SubscriptionsListenNotifications(); // A stateless session (Streamable HTTP with no session) cannot deliver out-of-band @@ -442,7 +692,7 @@ private void ConfigureSubscriptions(McpServerOptions options) // changes back to the client (tracked by #1662). Rather than hold the POST open forever only // to deliver nothing - pinning the connection and its request scope - acknowledge the listen // request granting no notifications and complete immediately. This runs after protocol - // negotiation, so it is not a legacy-server signal and never triggers a client fallback to the + // negotiation, so it is not an initialize-handshake-server signal and never triggers a client fallback to the // initialize handshake. if (!HasStatefulTransport()) { @@ -522,7 +772,7 @@ private sealed record ActiveSubscription(RequestId Id, SubscriptionsListenNotifi /// private async Task SendListChangedNotificationAsync(string notificationMethod) { - // Legacy clients never open a subscriptions/listen stream, so they keep the session-wide broadcast. + // Initialize-handshake clients never open a subscriptions/listen stream, so they keep the session-wide broadcast. // subscriptions/listen is a SEP-2575 feature, so clients on the 2026-07-28 or later revision instead get // a fan-out limited to the notification types they explicitly subscribed to. if (!IsJuly2026OrLaterProtocol()) @@ -805,8 +1055,8 @@ private void ConfigureTasks(McpServerOptions options) cancelTaskHandler ??= (static async (request, _) => throw new McpProtocolException($"Unknown task: '{request.Params?.TaskId}'", McpErrorCode.InvalidParams)); // The tasks/* methods do not exist before the 2026-07-28 revision (SEP-2663). Reject them with - // MethodNotFound when the request was negotiated under a legacy protocol version. The handlers - // stay registered so a dual-era server still serves them for 2026-07-28 requests. + // MethodNotFound when the request was negotiated under an initialize-handshake protocol version. The handlers + // stay registered so a dual-path server still serves them for 2026-07-28 requests. getTaskHandler = GateTaskMethodToJuly2026OrLaterProtocol(getTaskHandler, RequestMethods.TasksGet); updateTaskHandler = GateTaskMethodToJuly2026OrLaterProtocol(updateTaskHandler, RequestMethods.TasksUpdate); cancelTaskHandler = GateTaskMethodToJuly2026OrLaterProtocol(cancelTaskHandler, RequestMethods.TasksCancel); @@ -883,7 +1133,7 @@ subscribeHandler is null && unsubscribeHandler is null && resources is null && listResourceTemplatesHandler ??= (static async (_, __) => new ListResourceTemplatesResult()); readResourceHandler ??= (static async (request, _) => { - var errorCode = McpHttpHeaders.UseInvalidParamsForMissingResource(request.Server.NegotiatedProtocolVersion) + var errorCode = McpProtocolVersions.UseInvalidParamsForMissingResource(request.Server.NegotiatedProtocolVersion) ? McpErrorCode.InvalidParams : McpErrorCode.ResourceNotFound; throw new McpProtocolException($"Unknown resource URI: '{request.Params?.Uri}'", errorCode); @@ -1560,6 +1810,13 @@ private void ConfigureLogging(McpServerOptions options) RequestMethods.LoggingSetLevel, (request, jsonRpcRequest, cancellationToken) => { + if (IsJuly2026OrLaterProtocolRequest(jsonRpcRequest)) + { + throw new McpProtocolException( + $"The method '{RequestMethods.LoggingSetLevel}' is not available on protocol version '{jsonRpcRequest.Context?.ProtocolVersion ?? NegotiatedProtocolVersion}'. Use per-request _meta/{MetaKeys.LogLevel} instead.", + McpErrorCode.MethodNotFound); + } + // Store the provided level. if (request is not null) { @@ -1794,7 +2051,7 @@ private bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request) => /// internal bool IsJuly2026OrLaterProtocolRequest(JsonRpcMessageContext? requestContext) => - McpHttpHeaders.IsJuly2026OrLaterProtocolVersion( + McpProtocolVersions.IsJuly2026OrLaterProtocolVersion( requestContext?.ProtocolVersion ?? NegotiatedProtocolVersion); /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs index eb99913d5..82f925ff4 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs @@ -14,7 +14,7 @@ public sealed class McpServerOptions /// Gets or sets information about this server implementation, including its name and version. /// /// - /// This information is sent to the client during initialization to identify the server. + /// This information is sent to the client during initialization or discovery to identify the server. /// It's displayed in client logs and can be used for debugging and compatibility checks. /// public Implementation? ServerInfo { get; set; } @@ -33,11 +33,23 @@ public sealed class McpServerOptions /// Gets or sets the protocol version supported by this server, using a date-based versioning scheme. /// /// - /// The protocol version defines which features and message formats this server supports. - /// This uses a date-based versioning scheme in the format "YYYY-MM-DD". - /// If , the server will advertise to the client the version requested - /// by the client if that version is known to be supported, and otherwise will advertise the latest - /// version supported by the server. + /// + /// The protocol version defines which features and message formats this server supports. Supported + /// values are 2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25, and + /// 2026-07-28. + /// + /// + /// If , the server supports all of the versions listed above. For clients using + /// the initialize handshake, the server returns the requested initialize-capable version when it + /// is supported and otherwise returns 2025-11-25. For clients using server/discover and + /// per-request metadata, the server advertises the supported per-request metadata versions; currently + /// this is 2026-07-28. + /// + /// + /// Set this property to a specific supported value to pin the server to that version. Setting it to + /// 2026-07-28 makes the server reject initialize handshakes; setting it to an earlier + /// value makes the server reject 2026-07-28 per-request metadata. + /// /// public string? ProtocolVersion { get; set; } @@ -74,12 +86,13 @@ public sealed class McpServerOptions public bool ScopeRequests { get; set; } = true; /// - /// Gets or sets preexisting knowledge about the client including its name and version to help support - /// stateless Streamable HTTP servers that encode this knowledge in the mcp-session-id header. + /// Gets or sets preexisting knowledge about the client including its name and version. /// /// /// - /// When not specified, this information is sourced from the client's initialize request. + /// When not specified, this information is sourced from the client's initialize request or, + /// for protocol versions that use per-request metadata, from the current request's _meta field. + /// This is typically set during session migration in conjunction with . /// /// public Implementation? KnownClientInfo { get; set; } @@ -90,7 +103,8 @@ public sealed class McpServerOptions /// /// /// - /// When not specified, this information is sourced from the client's initialize request. + /// When not specified, this information is sourced from the client's initialize request or, + /// for protocol versions that use per-request metadata, from the current request's _meta field. /// This is typically set during session migration in conjunction with . /// /// diff --git a/tests/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs index aad326ba4..abd593ffc 100644 --- a/tests/Common/Utils/NodeHelpers.cs +++ b/tests/Common/Utils/NodeHelpers.cs @@ -179,17 +179,22 @@ public static bool IsNodeInstalled() } /// - /// Checks whether the SEP-2243 conformance scenarios are available, by reading the - /// installed conformance package version from node_modules. - /// The http-standard-headers, http-custom-headers, http-invalid-tool-headers, - /// http-header-validation, and http-custom-header-server-validation scenarios were - /// introduced in conformance package 0.2.0. Reading the installed version (rather than - /// the pinned version in package.json) means this also returns - /// when a newer private build has been installed locally via - /// npm install --no-save <path-to-conformance>. + /// Checks whether the SEP-2243 conformance scenarios are available in the installed + /// conformance package. /// public static bool HasSep2243Scenarios() - => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)); + => HasInstalledConformanceScenarios( + "http-standard-headers", + "http-invalid-tool-headers", + "http-header-validation", + "http-custom-header-server-validation"); + + /// + /// Checks whether the SEP-2575 request-metadata client conformance scenario is available + /// in the installed conformance package. + /// + public static bool HasRequestMetadataScenario() + => HasInstalledConformanceScenario("request-metadata"); /// /// Checks whether the installed conformance package contains a spec-conformant @@ -213,54 +218,45 @@ public static bool HasConformantCustomHeadersScenario() /// locally via npm install --no-save <path-to-conformance>. /// public static bool HasCachingScenario() - => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)); + => HasInstalledConformanceScenario("caching"); /// - /// Returns when the conformance package installed in node_modules - /// has a version greater than or equal to . + /// Checks whether all named conformance scenarios are present in the installed + /// @modelcontextprotocol/conformance bundle. This is intentionally based on the + /// installed scenario list rather than the package version so prerelease/private builds are + /// gated by the scenarios they actually contain. /// - private static bool HasInstalledConformanceVersionAtLeast(Version minimumVersion) - { - var version = GetInstalledConformanceVersion(); - return version is not null && version >= minimumVersion; - } + private static bool HasInstalledConformanceScenarios(params string[] scenarioNames) + => ReadInstalledConformanceBundle() is { } bundle + && scenarioNames.All(scenarioName => HasInstalledConformanceScenario(bundle, scenarioName)); - /// - /// Reads the version of the conformance package actually installed in node_modules, - /// stripping any prerelease/build suffix (e.g. "0.2.0-alpha.1" -> "0.2.0") so it can be - /// parsed as a . Returns if it cannot be - /// determined. - /// - private static Version? GetInstalledConformanceVersion() + private static bool HasInstalledConformanceScenario(string scenarioName) + => ReadInstalledConformanceBundle() is { } bundle + && HasInstalledConformanceScenario(bundle, scenarioName); + + private static bool HasInstalledConformanceScenario(string bundle, string scenarioName) + => bundle.Contains($"`{scenarioName}`", StringComparison.Ordinal) || + bundle.Contains($"\"{scenarioName}\"", StringComparison.Ordinal) || + bundle.Contains($"'{scenarioName}'", StringComparison.Ordinal); + + private static string? ReadInstalledConformanceBundle() { try { var repoRoot = FindRepoRoot(); - var packageJsonPath = Path.Combine( - repoRoot, "node_modules", "@modelcontextprotocol", "conformance", "package.json"); + var bundlePath = Path.Combine( + repoRoot, "node_modules", "@modelcontextprotocol", "conformance", "dist", "index.js"); - // This is a skip gate for version-conditional conformance scenarios, so it must stay - // side-effect-free. If the conformance package isn't installed, report no version (the + // This is a skip gate for scenario-conditional conformance tests, so it must stay + // side-effect-free. If the conformance package isn't installed, report no bundle (the // scenario is simply gated off); the actual scenario run path restores npm dependencies // separately via ConformanceTestStartInfo. - if (!File.Exists(packageJsonPath)) + if (!File.Exists(bundlePath)) { return null; } - using var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(packageJsonPath)); - if (json.RootElement.TryGetProperty("version", out var versionElement) && - versionElement.GetString() is { } versionStr) - { - // Strip any prerelease/build suffix so System.Version can parse it. - var core = versionStr.Split('-', '+')[0]; - if (Version.TryParse(core, out var version)) - { - return version; - } - } - - return null; + return File.ReadAllText(bundlePath); } catch { @@ -459,7 +455,7 @@ private static (Version Core, string[] Prerelease) SplitSemVer(string version) await process.WaitForExitAsync(cts.Token); #else // net472 lacks the CancellationToken overload; fall back to the timeout-based polyfill - // extension and surface a timeout the same way the modern path does. + // extension and surface a timeout the same way the current target-framework path does. await process.WaitForExitAsync(TimeSpan.FromMinutes(5)); if (!process.HasExited) { @@ -520,16 +516,24 @@ private static bool ConformanceOutputIndicatesSuccess(string output) /// /// Checks whether the SEP-2322 (Multi Round-Trip Requests / InputRequiredResult) - /// conformance scenarios are available, by reading the installed conformance - /// package version from node_modules. The incomplete-result-* scenarios were - /// introduced in conformance package 0.2.0 (see - /// https://github.com/modelcontextprotocol/conformance/pull/188). - /// Reading the installed version (rather than the pinned version in package.json) means - /// this also returns when a newer private build has been installed - /// locally via npm install --no-save <path-to-conformance>. + /// conformance scenarios are available in the installed conformance package. /// public static bool HasMrtrScenarios() - => HasInstalledConformanceVersionAtLeast(new Version(0, 2, 0)); + => HasInstalledConformanceScenarios( + "input-required-result-basic-elicitation", + "input-required-result-basic-sampling", + "input-required-result-basic-list-roots", + "input-required-result-request-state", + "input-required-result-multiple-input-requests", + "input-required-result-multi-round", + "input-required-result-missing-input-response", + "input-required-result-non-tool-request", + "input-required-result-result-type", + "input-required-result-unsupported-methods", + "input-required-result-tampered-state", + "input-required-result-capability-check", + "input-required-result-ignore-extra-params", + "input-required-result-validate-input"); private static ProcessStartInfo NpmStartInfo(string arguments, string workingDirectory) { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs index 38e503258..e23a097e5 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs @@ -108,7 +108,7 @@ public async ValueTask DisposeAsync() /// /// The scenario was introduced in spec wire version 2026-07-28 and uses the stateless lifecycle. /// It is gated on the installed conformance -/// package version (>= 0.2.0). The stateless server is +/// package's scenario list. The stateless server is /// started only after the gates pass, so a skipped run binds no port. /// public class CachingConformanceTests(ITestOutputHelper output) @@ -119,7 +119,7 @@ public async Task RunCachingConformanceTest() Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); Assert.SkipWhen( !NodeHelpers.HasCachingScenario(), - "SEP-2549 caching conformance scenario not available (requires conformance package >= 0.2.0)."); + "SEP-2549 caching conformance scenario is not available in the installed conformance package."); await using var server = await StatelessConformanceServer.StartAsync(TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs index e21bf4e67..51eba1f83 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs @@ -17,6 +17,7 @@ public class ClientConformanceTests // Public static property required for SkipUnless attribute public static bool IsNodeInstalled => NodeHelpers.IsNodeInstalled(); public static bool HasSep2243Scenarios => NodeHelpers.HasSep2243Scenarios(); + public static bool HasRequestMetadataScenario => NodeHelpers.HasRequestMetadataScenario(); public static bool HasConformantCustomHeadersScenario => NodeHelpers.HasConformantCustomHeadersScenario(); public ClientConformanceTests(ITestOutputHelper output) @@ -45,7 +46,7 @@ public ClientConformanceTests(ITestOutputHelper output) [InlineData("auth/resource-mismatch")] [InlineData("auth/pre-registration")] - // Backcompat: Legacy 2025-03-26 OAuth flows (no PRM, root-location metadata). + // Backcompat: 2025-03-26 OAuth flows (no per-request metadata, root-location metadata). [InlineData("auth/2025-03-26-oauth-metadata-backcompat")] [InlineData("auth/2025-03-26-oauth-endpoint-fallback")] @@ -63,8 +64,18 @@ public async Task RunConformanceTest(string scenario) $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); } + // Per-request metadata (SEP-2575) + [Fact(Skip = "SEP-2575 request-metadata conformance scenario is not available in the installed conformance package.", SkipUnless = nameof(HasRequestMetadataScenario))] + public async Task RunConformanceTest_RequestMetadata() + { + var result = await RunClientConformanceScenario("request-metadata"); + + Assert.True(result.Success, + $"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); + } + // HTTP Standardization (SEP-2243) - [Theory(Skip = "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0).", SkipUnless = nameof(HasSep2243Scenarios))] + [Theory(Skip = "SEP-2243 conformance scenarios are not available in the installed conformance package.", SkipUnless = nameof(HasSep2243Scenarios))] [InlineData("http-standard-headers")] [InlineData("http-invalid-tool-headers")] public async Task RunConformanceTest_Sep2243(string scenario) diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs index 4da16a3eb..32e3d00b0 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpHeaderConformanceTests.cs @@ -114,7 +114,7 @@ private static McpServerTool CreateUnionHeaderTestTool() public async Task Server_AcceptsUnionIntegerCanonicalForm() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Union-typed (["integer","null"]) parameter: header carries canonical "42" while the body // carries the decimal form 42.0. The server must treat the union type as integer and match. @@ -135,7 +135,7 @@ public async Task Server_AcceptsUnionIntegerCanonicalForm() public async Task Server_RejectsUnionIntegerOutsideSafeRange() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); var callJson = CallTool("union_test", """{"priority":9007199254740993}"""); @@ -154,7 +154,7 @@ public async Task Server_RejectsUnionIntegerOutsideSafeRange() public async Task Server_AcceptsExponentBodyMatchingDecimalHeader() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Body carries the integer in exponent form (1e2 = 100); header carries the decimal "100". var callJson = CallTool("header_test", """{"region":"test","priority":1e2,"verbose":false,"emptyVal":""}"""); @@ -177,7 +177,7 @@ public async Task Server_AcceptsExponentBodyMatchingDecimalHeader() public async Task Server_AcceptsWhitespaceAroundMcpNameHeaderValue() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Per SEP-2243: servers MUST accept extra whitespace around header values // and compare the trimmed value to the request body. @@ -201,7 +201,7 @@ public async Task Server_AcceptsWhitespaceAroundMcpNameHeaderValue() public async Task Server_AcceptsWhitespaceAroundMcpMethodHeaderValue() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Per SEP-2243: servers MUST accept extra whitespace around header values var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}"""); @@ -224,7 +224,7 @@ public async Task Server_AcceptsWhitespaceAroundMcpMethodHeaderValue() public async Task Server_ValidatesEmptyStringHeaderValue_AgainstBodyValue() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Send a tools/call with an empty string param that has an x-mcp-header. // The header should be present with an empty value, matching the body's empty string. @@ -248,7 +248,7 @@ public async Task Server_ValidatesEmptyStringHeaderValue_AgainstBodyValue() public async Task Server_RejectsHeaderMismatch_WhenEmptyHeaderDoesNotMatchBody() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Send a tools/call where the body has a non-empty value but the header is empty var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":"some-value"}"""); @@ -271,7 +271,7 @@ public async Task Server_RejectsHeaderMismatch_WhenEmptyHeaderDoesNotMatchBody() public async Task Server_AcceptsBase64EncodedHeaderWithControlChars() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Encode a value with a newline control character using Base64 var valueWithNewline = "line1\nline2"; @@ -297,7 +297,7 @@ public async Task Server_AcceptsBase64EncodedHeaderWithControlChars() public async Task Server_AcceptsMaxSafeIntegerWithFullPrecision() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // The maximum safe integer (2^53 - 1) must be accepted, and compared exactly without // losing precision through a double conversion. @@ -326,7 +326,7 @@ public async Task Server_AcceptsMaxSafeIntegerWithFullPrecision() public async Task Server_RejectsIntegerOutsideSafeRange(string outOfRangeValue) { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Per SEP-2243 integer values MUST be within the JavaScript safe integer range. // A matching header and body that are both outside the range must still be rejected. @@ -355,7 +355,7 @@ public async Task Server_RejectsIntegerOutsideSafeRange(string outOfRangeValue) public async Task Server_AcceptsNumericEquivalentHeaderValues(string headerValue, string bodyValue) { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // bodyValue is inserted as a raw JSON numeric literal so that forms such as "42.0" and // "4.2e1" are preserved in the body exactly as another SDK might serialize them. @@ -382,7 +382,7 @@ public async Task Server_AcceptsNumericEquivalentHeaderValues(string headerValue public async Task Server_RejectsNonIntegerValue_EvenWhenHeaderAndBodyMatch(string nonIntegerValue) { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // For an integer-typed parameter a non-whole numeric value is invalid and must be rejected // even when the header and body strings are byte-for-byte identical (it must not slip through @@ -407,7 +407,7 @@ public async Task Server_RejectsNonIntegerValue_EvenWhenHeaderAndBodyMatch(strin public async Task Server_RejectsNonNumericMismatch_ForIntegerParam() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Header says "99" but body says priority:42 — must reject even with numeric comparison var callJson = CallTool("header_test", """{"region":"test","priority":42,"verbose":false,"emptyVal":""}"""); @@ -427,17 +427,17 @@ public async Task Server_RejectsNonNumericMismatch_ForIntegerParam() } [Fact] - public async Task Server_SkipsHeaderValidation_ForLegacyVersion() + public async Task Server_SkipsHeaderValidation_ForInitializeHandshakeVersion() { await StartAsync(); - await InitializeWithLegacyVersionAsync(); + await InitializeWithInitializeHandshakeVersionAsync(); - // With the legacy version, Mcp-Param-* headers are NOT validated even if mismatched - var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}"""); + // With the initialize-handshake version, Mcp-Param-* headers are NOT validated even if mismatched. + var callJson = CallTool("header_test", """{"region":"us-west1","priority":42,"verbose":false,"emptyVal":""}""", includePerRequestMetadata: false); using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = new StringContent(callJson, Encoding.UTF8, "application/json"); - // Send the WRONG header value. This should still succeed because the version is legacy. + // Send the WRONG header value. This should still succeed because the version uses initialize. request.Headers.Add("MCP-Protocol-Version", "2025-11-25"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "header_test"); @@ -451,7 +451,7 @@ public async Task Server_SkipsHeaderValidation_ForLegacyVersion() public async Task Server_RejectsInvalidUtf8EncodedHeaderValue() { await StartAsync(); - await InitializeWithJuly2026ProtocolVersionAsync(); + await ProbeWithJuly2026ProtocolVersionAsync(); // Create a separate HttpClient that sends raw UTF-8 bytes in Mcp-* headers // instead of properly base64-encoding non-ASCII values. @@ -561,40 +561,39 @@ public void Client_EncodeValue_Boolean_EncodesCorrectly() [InlineData("2024-11-05", false)] [InlineData(null, false)] [InlineData("", false)] - public void SupportsStandardHeaders_CorrectlyGatesVersions(string? version, bool expected) + public void RequiresStandardHeaders_CorrectlyGatesVersions(string? version, bool expected) { - Assert.Equal(expected, McpHttpHeaders.SupportsStandardHeaders(version)); + Assert.Equal(expected, McpProtocolVersions.RequiresStandardHeaders(version)); } #endregion #region Helpers - private async Task InitializeWithJuly2026ProtocolVersionAsync() + private async Task ProbeWithJuly2026ProtocolVersionAsync() { HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); using var request = new HttpRequestMessage(HttpMethod.Post, ""); - request.Content = JsonContent(InitializeRequestJuly2026Protocol); + request.Content = JsonContent(DiscoverRequestJuly2026Protocol); request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); - request.Headers.Add("Mcp-Method", "initialize"); + request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - // Starting with the 2026-07-28 protocol revision (SEP-2567), Streamable HTTP does not return a - // mcp-session-id header. Subsequent requests carry MCP-Protocol-Version=2026-07-28 - // so each one is handled independently. + // Starting with the 2026-07-28 protocol revision, clients use server/discover and per-request + // metadata instead of initialize. } - private async Task InitializeWithLegacyVersionAsync() + private async Task InitializeWithInitializeHandshakeVersionAsync() { HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); using var response = await HttpClient.PostAsync("", JsonContent(InitializeRequest), TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - // Server is stateless by default (SEP-2567), so initializing with the legacy protocol does not return + // Server is stateless by default (SEP-2567), so initializing with an initialize-handshake protocol does not return // a mcp-session-id header. Subsequent requests are independent, just like requests on the 2026-07-28 revision. } @@ -602,22 +601,24 @@ private async Task InitializeWithLegacyVersionAsync() private long _lastRequestId = 1; - private string CallTool(string toolName, string arguments = "{}") + private string CallTool(string toolName, string arguments = "{}", bool includePerRequestMetadata = true) { var id = Interlocked.Increment(ref _lastRequestId); - return $$$""" - {"jsonrpc":"2.0","id":{{{id}}},"method":"tools/call","params":{"name":"{{{toolName}}}","arguments":{{{arguments}}}}} - """; + var meta = includePerRequestMetadata + ? @",""_meta"":{""io.modelcontextprotocol/protocolVersion"":""2026-07-28"",""io.modelcontextprotocol/clientInfo"":{""name"":""TestClient"",""version"":""1.0""},""io.modelcontextprotocol/clientCapabilities"":{}}" + : ""; + + return "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"tools/call\",\"params\":{\"name\":\"" + + toolName + "\",\"arguments\":" + arguments + meta + "}}"; } private static string InitializeRequest => """ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0"}}} """; - private static string InitializeRequestJuly2026Protocol => """ - {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"TestClient","version":"1.0"}}} + private static string DiscoverRequestJuly2026Protocol => """ + {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"TestClient","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}} """; #endregion } - diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs index 02bfba288..12bae02bb 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpFallbackTests.cs @@ -14,7 +14,8 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// -/// Regression tests for the 2026-07-28-to-legacy fallback path over Streamable HTTP. These +/// Regression tests for the fallback from a 2026-07-28 per-request-metadata probe to the initialize +/// handshake over Streamable HTTP. These /// hand-craft minimal HTTP servers that mimic real-world peer behavior (e.g. Python's /// simple-streamablehttp-stateless returns a JSON-RPC error envelope in a 400 body /// on a 2026-07-28 probe; vanilla Go does the same on POST /) so the client's HTTP-fallback @@ -29,15 +30,15 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// /// only surfaced the three error codes /// introduced by the 2026-07-28 revision (-32022, -32021, -32020) as ; -/// any other JSON-RPC error code in a 400 body (e.g. -32600 from a legacy server +/// any other JSON-RPC error code in a 400 body (e.g. -32600 from an initialize-handshake server /// that doesn't understand the 2026-07-28 _meta envelope) threw /// and bypassed the connect-time fallback logic. Per spec PR #2844, the fallback must trigger -/// on ANY non-modern JSON-RPC error in a 400 body. +/// on ANY non-SEP-2575 JSON-RPC error in a 400 body. /// /// /// treated any non-2xx HTTP response as a /// signal to abandon the Streamable HTTP transport and fall back to SSE. That masked -/// application-level errors (including the three modern codes) because the SSE GET would +/// application-level errors (including the three SEP-2575/SEP-2567 codes) because the SSE GET would /// either fail with "session id required" or succeed against a different endpoint and lose /// the actual signal. /// @@ -86,11 +87,11 @@ private static async Task WriteJsonRpcErrorAsync(HttpContext context, HttpStatus /// /// Mimics Python's simple-streamablehttp-stateless on a 2026-07-28 probe: returns /// 400 + JSON-RPC -32600 ("Bad Request: Unsupported protocol version") for the - /// initial server/discover, then performs a normal legacy initialize handshake + /// initial server/discover, then performs a normal initialize handshake /// when the client falls back. /// [Fact] - public async Task Client_AgainstLegacyHttpServer_FallsBack_To_Initialize_When_400_Contains_JsonRpcError() + public async Task Client_AgainstInitializeHandshakeHttpServer_FallsBack_To_Initialize_When_400_Contains_JsonRpcError() { var ct = TestContext.Current.CancellationToken; @@ -107,7 +108,7 @@ await StartServerAsync(async context => return; } - // 2026-07-28 probe: simulate a legacy server that rejects the unknown protocol version with + // 2026-07-28 probe: simulate an initialize-handshake server that rejects the unknown protocol version with // a -32600 envelope (matches Python's wire shape verified in cross-SDK testing). if (request.Method == RequestMethods.ServerDiscover) { @@ -115,7 +116,7 @@ await StartServerAsync(async context => return; } - // Legacy initialize: respond with the highest version the legacy server speaks. + // Initialize handshake: respond with the highest version this server speaks. if (request.Method == RequestMethods.Initialize) { var response = new JsonRpcResponse @@ -123,9 +124,9 @@ await StartServerAsync(async context => Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "2025-06-18", + ProtocolVersion = McpProtocolVersions.June2025ProtocolVersion, Capabilities = new() { Tools = new() }, - ServerInfo = new Implementation { Name = "legacy", Version = "1.0" }, + ServerInfo = new Implementation { Name = "initialize-handshake", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions), }; @@ -157,11 +158,11 @@ await StartServerAsync(async context => Endpoint = new("http://localhost:5000/mcp"), }, HttpClient, LoggerFactory); - // Default options prefer 2026-07-28 but allow automatic fallback to a legacy server. + // Default options prefer 2026-07-28 but allow automatic fallback to an initialize-handshake server. await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), loggerFactory: LoggerFactory, cancellationToken: ct); - Assert.Equal("2025-06-18", client.NegotiatedProtocolVersion); + Assert.Equal(McpProtocolVersions.June2025ProtocolVersion, client.NegotiatedProtocolVersion); // Sanity: subsequent traffic still works post-fallback. var tools = await client.ListToolsAsync(cancellationToken: ct); @@ -170,7 +171,7 @@ await StartServerAsync(async context => /// /// Mimics vanilla Go: returns 400 + JSON-RPC -32022 with - /// data.supported[] on a 2026-07-28 probe so the client retries legacy + /// data.supported[] on a 2026-07-28 probe so the client retries /// initialize with one of the advertised versions. /// [Fact] @@ -197,8 +198,8 @@ await StartServerAsync(async context => // Use the typed payload type so the source-generated serializer can handle it. var data = JsonSerializer.SerializeToNode(new UnsupportedProtocolVersionErrorData { - Supported = new List { "2025-11-25" }, - Requested = "2026-07-28", + Supported = new List { McpProtocolVersions.November2025ProtocolVersion }, + Requested = McpProtocolVersions.July2026ProtocolVersion, }, GetJsonTypeInfo()); var rpcError = new JsonRpcError @@ -225,7 +226,7 @@ await StartServerAsync(async context => Id = request.Id, Result = JsonSerializer.SerializeToNode(new InitializeResult { - ProtocolVersion = "2025-11-25", + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, Capabilities = new() { Tools = new() }, ServerInfo = new Implementation { Name = "go-shaped", Version = "1.0" }, }, McpJsonUtilities.DefaultOptions), @@ -244,16 +245,16 @@ await StartServerAsync(async context => Endpoint = new("http://localhost:5000/mcp"), }, HttpClient, LoggerFactory); - // Default options prefer 2026-07-28 but allow automatic fallback to a legacy server. + // Default options prefer 2026-07-28 but allow automatic fallback to an initialize-handshake server. await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), loggerFactory: LoggerFactory, cancellationToken: ct); - Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); } /// /// A 400 with a JSON-RPC -32020 HeaderMismatch envelope must be surfaced to the - /// caller (no legacy fallback). Falling back wouldn't fix a malformed envelope. + /// caller (no initialize fallback). Falling back wouldn't fix a malformed envelope. /// [Fact] public async Task Client_OnHeaderMismatch_400_Surfaces_McpProtocolException_NoFallback() @@ -293,11 +294,133 @@ await WriteJsonRpcErrorAsync(context, HttpStatusCode.BadRequest, { await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { - ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion, + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, }, loggerFactory: LoggerFactory, cancellationToken: ct); }); Assert.Equal(McpErrorCode.HeaderMismatch, exception.ErrorCode); Assert.False(initializeReceived); } + + [Fact] + public async Task Client_OnPerRequestMetadataResponseWithMcpSessionId_IgnoresSessionState() + { + var ct = TestContext.Current.CancellationToken; + string? toolsListSessionId = null; + + await StartServerAsync(async context => + { + var message = await JsonSerializer.DeserializeAsync( + context.Request.Body, + GetJsonTypeInfo(), + ct); + + if (message is JsonRpcRequest { Method: RequestMethods.ServerDiscover } request) + { + var response = new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new DiscoverResult + { + SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion], + Capabilities = new ServerCapabilities(), + ServerInfo = new Implementation { Name = "bad-per-request-metadata-server", Version = "1.0" }, + TimeToLive = TimeSpan.Zero, + CacheScope = CacheScope.Private, + }, McpJsonUtilities.DefaultOptions), + }; + + context.Response.Headers[McpHttpHeaders.SessionId] = "unexpected-session"; + context.Response.ContentType = "application/json"; + await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo(), ct); + return; + } + + if (message is JsonRpcRequest { Method: RequestMethods.ToolsList } toolsListRequest) + { + toolsListSessionId = context.Request.Headers[McpHttpHeaders.SessionId].ToString(); + + var response = new JsonRpcResponse + { + Id = toolsListRequest.Id, + Result = JsonSerializer.SerializeToNode(new ListToolsResult { Tools = [] }, McpJsonUtilities.DefaultOptions), + }; + + context.Response.ContentType = "application/json"; + await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo(), ct); + return; + } + + context.Response.StatusCode = StatusCodes.Status202Accepted; + }); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + + await client.ListToolsAsync(cancellationToken: ct); + + Assert.Null(client.SessionId); + Assert.Equal("", toolsListSessionId); + } + + [Fact] + public async Task Client_WithKnownSessionId_DoesNotEchoIt_OnPerRequestMetadataRequest() + { + var ct = TestContext.Current.CancellationToken; + string? discoverSessionId = null; + + await StartServerAsync(async context => + { + var message = await JsonSerializer.DeserializeAsync( + context.Request.Body, + GetJsonTypeInfo(), + ct); + + if (message is JsonRpcRequest { Method: RequestMethods.ServerDiscover } request) + { + discoverSessionId = context.Request.Headers[McpHttpHeaders.SessionId].ToString(); + + var response = new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(new DiscoverResult + { + SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion], + Capabilities = new ServerCapabilities(), + ServerInfo = new Implementation { Name = "per-request-metadata-server", Version = "1.0" }, + TimeToLive = TimeSpan.Zero, + CacheScope = CacheScope.Private, + }, McpJsonUtilities.DefaultOptions), + }; + + context.Response.ContentType = "application/json"; + await JsonSerializer.SerializeAsync(context.Response.Body, response, GetJsonTypeInfo(), ct); + return; + } + + context.Response.StatusCode = StatusCodes.Status202Accepted; + }); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new("http://localhost:5000/mcp"), + TransportMode = HttpTransportMode.StreamableHttp, + KnownSessionId = "legacy-session", + OwnsSession = false, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions + { + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, + }, loggerFactory: LoggerFactory, cancellationToken: ct); + + Assert.Equal("", discoverSessionId); + } } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs index 9cce9b0db..ab9d04b70 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolHttpHandlerTests.cs @@ -10,7 +10,7 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// /// HTTP-level tests for the 2026-07-28 protocol revision (SEP-2575 + SEP-2567): verify that the server -/// suppresses the Mcp-Session-Id header for those requests and returns structured +/// does not issue Mcp-Session-Id for those requests and returns structured /// errors instead of plain 400s. /// public class July2026ProtocolHttpHandlerTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable @@ -52,12 +52,12 @@ public async Task Request_OnStatelessServer_Succeeds_WithoutMcpSessionIdHeader() { await StartAsync(stateless: true); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); // On a stateless server, server/discover succeeds without creating a session. var content = new StringContent( - """{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""", + DiscoverRequestJuly2026Protocol, Encoding.UTF8, "application/json"); using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); @@ -70,15 +70,15 @@ public async Task Request_OnStatefulServer_IsRefused_WithUnsupportedProtocolVers { // Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567), // so the server cannot honor it when configured with sessions (Stateless = false). The server refuses that - // version with UnsupportedProtocolVersion (excluding it from Supported) so a dual-era client falls back - // to the legacy initialize handshake. + // version with UnsupportedProtocolVersion (excluding it from Supported) so a dual-path client falls back + // to the initialize handshake. await StartAsync(stateless: false); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); var content = new StringContent( - """{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""", + DiscoverRequestJuly2026Protocol, Encoding.UTF8, "application/json"); using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); @@ -93,22 +93,22 @@ public async Task Request_OnStatefulServer_IsRefused_WithUnsupportedProtocolVers var dataElement = (JsonElement)rpcError.Error.Data!; var errorData = dataElement.Deserialize(McpJsonUtilities.DefaultOptions); Assert.NotNull(errorData); - Assert.Equal(McpHttpHeaders.July2026ProtocolVersion, errorData.Requested); - // The 2026-07-28 protocol version is excluded from Supported so the client downgrades to a legacy version. + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, errorData.Requested); + // The 2026-07-28 protocol version is excluded from Supported so the client downgrades to an initialize-capable version. Assert.NotEmpty(errorData.Supported); - Assert.DoesNotContain(McpHttpHeaders.July2026ProtocolVersion, errorData.Supported); + Assert.DoesNotContain(McpProtocolVersions.July2026ProtocolVersion, errorData.Supported); } [Fact] public async Task RequestWithUnsupportedProtocolVersion_Returns_UnsupportedProtocolVersionError() { - await StartAsync(); + await StartAsync(stateless: true); HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", "2099-12-31"); HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); var content = new StringContent( - """{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""", + DiscoverRequestJuly2026Protocol, Encoding.UTF8, "application/json"); using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); @@ -128,23 +128,23 @@ public async Task RequestWithUnsupportedProtocolVersion_Returns_UnsupportedProto } [Fact] - public async Task Request_WithMcpSessionIdHeader_IsRejected() + public async Task Request_WithMcpSessionIdHeader_IgnoresHeader_AndDoesNotEchoSessionId() { // Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567): - // a request carrying an Mcp-Session-Id is non-conformant and is rejected with 400 regardless of the - // Stateless setting. - await StartAsync(); + // a request carrying an Mcp-Session-Id is ignored, and the server must not mint or echo session IDs. + await StartAsync(stateless: true); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); HttpClient.DefaultRequestHeaders.Add("Mcp-Method", "server/discover"); HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id"); var content = new StringContent( - """{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}""", + DiscoverRequestJuly2026Protocol, Encoding.UTF8, "application/json"); using var response = await HttpClient.PostAsync("", content, TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.False(response.Headers.Contains("Mcp-Session-Id")); } [Fact] @@ -152,11 +152,12 @@ public async Task Get_WithoutSessionId_IsRejected() { await StartAsync(); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + Assert.Equal(["POST"], response.Content.Headers.Allow); } [Fact] @@ -164,12 +165,13 @@ public async Task Get_WithSessionId_IsRejected() { await StartAsync(); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id"); using var response = await HttpClient.GetAsync("", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + Assert.Equal(["POST"], response.Content.Headers.Allow); } [Fact] @@ -177,11 +179,12 @@ public async Task Delete_WithoutSessionId_IsRejected() { await StartAsync(); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); using var response = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + Assert.Equal(["POST"], response.Content.Headers.Allow); } [Fact] @@ -189,11 +192,16 @@ public async Task Delete_WithSessionId_IsRejected() { await StartAsync(); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); HttpClient.DefaultRequestHeaders.Add("Mcp-Session-Id", "non-existent-session-id"); using var response = await HttpClient.DeleteAsync("", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); + Assert.Equal(["POST"], response.Content.Headers.Allow); } + + private static string DiscoverRequestJuly2026Protocol => """ + {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"July2026HttpHandlerTestClient","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}} + """; } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs index ef2d3f6aa..d593ff2f2 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/July2026ProtocolStatefulFallbackTests.cs @@ -14,9 +14,9 @@ namespace ModelContextProtocol.AspNetCore.Tests; /// server that deliberately opted into sessions ( /// is false). Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports /// sessions (SEP-2567 / SEP-2575), so the server refuses the probe with -32022 UnsupportedProtocolVersion. -/// The client must then auto-downgrade to the legacy initialize handshake, obtain the stateful session +/// The client must then auto-downgrade to the initialize handshake, obtain the stateful session /// the server author opted into, and continue to work, including a server→client elicitation round-trip -/// resolved over the stateful session via the legacy backcompat resolver. +/// resolved over the stateful session via the initialize-handshake backcompat resolver. /// public class July2026ProtocolStatefulFallbackTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable { @@ -38,7 +38,7 @@ public async ValueTask DisposeAsync() private static async Task GreetViaElicit(McpServer server, CancellationToken cancellationToken) { // Server→client round-trip: only works when the session is stateful, which is exactly what - // the legacy fallback re-establishes for the 2026-07-28-first client. + // the initialize fallback re-establishes for the 2026-07-28-first client. var elicitResult = await server.ElicitAsync(new ElicitRequestParams { Message = "What is your name?", @@ -77,21 +77,21 @@ private async Task ConnectDefaultClientAsync(Action }, HttpClient, LoggerFactory); // Default options: ProtocolVersion is null, which now prefers the 2026-07-28 protocol revision and probes - // with server/discover before falling back to a legacy initialize handshake. + // with server/discover before falling back to an initialize handshake. var clientOptions = new McpClientOptions(); configureClient?.Invoke(clientOptions); return await McpClient.CreateAsync(transport, clientOptions, LoggerFactory, TestContext.Current.CancellationToken); } [Fact] - public async Task DefaultClient_AgainstStatefulServer_DowngradesToLegacy_AndToolsWork() + public async Task DefaultClient_AgainstStatefulServer_DowngradesToInitialize_AndToolsWork() { await StartStatefulServerAsync(); await using var client = await ConnectDefaultClientAsync(); - // The 2026-07-28 probe was refused (-32022), so the client downgraded to legacy. - Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + // The 2026-07-28 probe was refused (-32022), so the client downgraded to initialize. + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("greet", new Dictionary { ["name"] = "Alice" }, @@ -118,7 +118,7 @@ public async Task DefaultClient_AgainstStatefulServer_ServerToClientElicitation_ }); }); - Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); var result = await client.CallToolAsync("greet_via_elicit", cancellationToken: TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj index eb036ad29..d75877bab 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj @@ -26,6 +26,7 @@ + diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs index 68076e292..aa724bb1b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/MrtrProtocolTests.cs @@ -147,7 +147,7 @@ static CallToolResult (RequestContext context) => // headers. No initialize handshake and no Mcp-Session-Id. HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); - HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + HttpClient.DefaultRequestHeaders.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); } public async ValueTask DisposeAsync() @@ -261,7 +261,7 @@ public async Task CapabilityCheck_OnlyEmitsInputRequestsForDeclaredCapabilities( public async Task BackcompatResolver_SendsServerRequestOverPostStream_WithoutGetStream() { // Configure a server that does NOT pin 2026-07-28 so it can negotiate the current - // protocol with a legacy client. The backcompat resolver path only runs when the + // initialize-handshake protocol. The backcompat resolver path only runs when the // negotiated version is not 2026-07-28. Builder.Services.AddMcpServer(options => { @@ -302,7 +302,7 @@ static string (RequestContext context) => HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); - // Initialize with the current legacy protocol so the server's backcompat resolver runs. + // Initialize with the current initialize-handshake protocol so the server's backcompat resolver runs. var initJson = """ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{"roots":{}},"clientInfo":{"name":"BackcompatTestClient","version":"1.0.0"}}} """; @@ -337,7 +337,7 @@ static string (RequestContext context) => // the response headers arrive instead of waiting for the SSE stream to close. var callRequest = new HttpRequestMessage(HttpMethod.Post, (string?)null) { - Content = JsonContent(CallTool("backcompat-roots-tool")), + Content = JsonContent(CallTool("backcompat-roots-tool", includePerRequestMetadata: false)), }; callRequest.Content.Headers.Add("Mcp-Method", "tools/call"); callRequest.Content.Headers.Add("Mcp-Name", "backcompat-roots-tool"); @@ -456,16 +456,49 @@ private Task PostJsonRpcAsync(string json) private long _lastRequestId = 1; - private string Request(string method, string parameters = "{}") + private string Request(string method, string parameters = "{}", bool includePerRequestMetadata = true) { var id = Interlocked.Increment(ref _lastRequestId); - return $$""" - {"jsonrpc":"2.0","id":{{id}},"method":"{{method}}","params":{{parameters}}} - """; + var paramsObj = JsonNode.Parse(parameters) as JsonObject ?? new JsonObject(); + if (includePerRequestMetadata) + { + AddJuly2026ProtocolMeta(paramsObj); + } + + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = id, + ["method"] = method, + ["params"] = paramsObj, + }; + + return request.ToJsonString(); + } + + private static void AddJuly2026ProtocolMeta(JsonObject paramsObj) + { + if (paramsObj["_meta"] is not JsonObject meta) + { + meta = []; + paramsObj["_meta"] = meta; + } + + meta[MetaKeys.ProtocolVersion] = McpProtocolVersions.July2026ProtocolVersion; + meta[MetaKeys.ClientInfo] = new JsonObject + { + ["name"] = "MrtrTestClient", + ["version"] = "1.0", + }; + + if (meta[MetaKeys.ClientCapabilities] is not JsonObject) + { + meta[MetaKeys.ClientCapabilities] = new JsonObject(); + } } - private string CallTool(string toolName, string arguments = "{}") => + private string CallTool(string toolName, string arguments = "{}", bool includePerRequestMetadata = true) => Request("tools/call", $$""" {"name":"{{toolName}}","arguments":{{arguments}}} - """); + """, includePerRequestMetadata); } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs index 366e4276d..edf58e743 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs @@ -23,12 +23,13 @@ public class RawHttpConformanceTests(ITestOutputHelper outputHelper) : KestrelIn private WebApplication? _app; - private async Task StartAsync() + private async Task StartAsync(string? protocolVersion = null) { Builder.Services .AddMcpServer(options => { options.ServerInfo = new Implementation { Name = nameof(RawHttpConformanceTests), Version = "1.0" }; + options.ProtocolVersion = protocolVersion; }) .WithHttpTransport() .WithTools([McpServerTool.Create((string text) => $"echo:{text}", new() { Name = "echo" })]); @@ -82,7 +83,7 @@ private static async Task ReadJsonResponseAsync(HttpResponseMessage re return JsonNode.Parse(body)!; } - private static string July2026ProtocolMetaFragment(string protocolVersion = McpHttpHeaders.July2026ProtocolVersion) => + private static string July2026ProtocolMetaFragment(string protocolVersion = McpProtocolVersions.July2026ProtocolVersion) => @"""_meta"":{""io.modelcontextprotocol/protocolVersion"":""" + protocolVersion + @""",""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," + @"""io.modelcontextprotocol/clientCapabilities"":{}}"; @@ -97,7 +98,7 @@ public async Task July2026ToolsCall_WithFullMeta_Succeeds_200() July2026ProtocolMetaFragment() + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; - request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion); + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "echo"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); @@ -119,14 +120,14 @@ public async Task ServerDiscover_RawPost_ReturnsDiscoverResult() var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; - request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion); + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); var supported = json["result"]!["supportedVersions"]!.AsArray().Select(n => n!.GetValue()).ToList(); - Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, supported); + Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], supported); // Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult; the server emits the // safest defaults (immediately stale, not shareable) when the application hasn't customized. @@ -135,6 +136,24 @@ public async Task ServerDiscover_RawPost_ReturnsDiscoverResult() Assert.Equal("private", json["result"]!["cacheScope"]!.GetValue()); } + [Fact] + public async Task ServerDiscover_WithConfiguredPerRequestMetadataProtocol_ReturnsOnlyConfiguredVersion() + { + await StartAsync(McpProtocolVersions.July2026ProtocolVersion); + + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", "server/discover"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + var supported = json["result"]!["supportedVersions"]!.AsArray().Select(n => n!.GetValue()).ToList(); + Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], supported); + } + [Fact] public async Task July2026Post_WithUnsupportedProtocolVersionHeader_Returns400_With_Minus32022() { @@ -151,7 +170,7 @@ public async Task July2026Post_WithUnsupportedProtocolVersionHeader_Returns400_W using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); // Per spec/streamable-http.mdx the server MUST return 400 Bad Request with -32022 and a data payload - // listing the supported versions. The dual-era client uses this to switch versions without fallback. + // listing the supported versions. The dual-path client uses this to switch versions without fallback. Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, json["error"]!["code"]!.GetValue()); @@ -160,7 +179,7 @@ public async Task July2026Post_WithUnsupportedProtocolVersionHeader_Returns400_W Assert.NotNull(data); Assert.Equal("9999-99-99", data!["requested"]!.GetValue()); var supported = data["supported"]!.AsArray().Select(n => n!.GetValue()).ToList(); - Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, supported); + Assert.Contains(McpProtocolVersions.July2026ProtocolVersion, supported); } [Fact] @@ -171,17 +190,18 @@ public async Task July2026Post_ProtocolVersionHeaderMetaMismatch_ReturnsHeaderMi // The MCP-Protocol-Version header declares the 2026-07-28 protocol revision, but the per-request _meta declares a // different (still individually supported) version. Per SEP-2575 the server MUST reject the // disagreement. It uses -32020 HeaderMismatch (the same code as the Mcp-Method/Mcp-Name header-vs-body - // checks) so a conformant client on this revision surfaces the error instead of mistaking the modern server for a - // legacy one and falling back to the initialize handshake. + // checks) so a conformant client on this revision surfaces the error instead of mistaking the + // per-request-metadata server for an initialize-handshake one and falling back to initialize. var body = @"{""jsonrpc"":""2.0"",""id"":4242,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment("2025-11-25") + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; - request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion); + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue()); @@ -203,7 +223,7 @@ public async Task July2026Post_MissingMcpNameHeader_ReturnsHeaderMismatch_Echoes July2026ProtocolMetaFragment() + "}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; - request.Headers.Add(ProtocolVersionHeader, McpHttpHeaders.July2026ProtocolVersion); + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "tools/call"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); @@ -214,11 +234,87 @@ public async Task July2026Post_MissingMcpNameHeader_ReturnsHeaderMismatch_Echoes } [Fact] - public async Task LegacyInitialize_StillSucceeds_OnDefaultServer() + public async Task July2026Post_WithServerPinnedToInitializeHandshakeVersion_ReturnsUnsupportedProtocolVersion() + { + await StartAsync(McpProtocolVersions.November2025ProtocolVersion); + + var body = + @"{""jsonrpc"":""2.0"",""id"":1,""method"":""tools/call"",""params"":{""name"":""echo"",""arguments"":{""text"":""x""}," + + July2026ProtocolMetaFragment() + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", "tools/call"); + request.Headers.Add("Mcp-Name", "echo"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, json["error"]!["code"]!.GetValue()); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, json["error"]!["data"]!["requested"]!.GetValue()); + + var supported = json["error"]!["data"]!["supported"]!.AsArray().Select(n => n!.GetValue()).ToList(); + Assert.Equal([McpProtocolVersions.November2025ProtocolVersion], supported); + } + + [Fact] + public async Task July2026Post_MissingBodyProtocolVersion_ReturnsHeaderMismatch_Minus32020() + { + await StartAsync(); + + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{""_meta"":{""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""},""io.modelcontextprotocol/clientCapabilities"":{}}}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", "server/discover"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue()); + Assert.Contains(MetaKeys.ProtocolVersion, json["error"]!["message"]!.GetValue(), StringComparison.Ordinal); + } + + [Fact] + public async Task July2026Post_MissingProtocolVersionHeader_ReturnsHeaderMismatch_Minus32020() + { + await StartAsync(); + + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add("Mcp-Method", "server/discover"); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue()); + Assert.Contains(ProtocolVersionHeader, json["error"]!["message"]!.GetValue(), StringComparison.Ordinal); + } + + [Fact] + public async Task Initialize_WithPerRequestMetadataProtocolHeaderAndInitializeBody_ReturnsHeaderMismatch_Minus32020() { await StartAsync(); - var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""version"":""1.0""}}}"; + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; + request.Headers.Add(ProtocolVersionHeader, McpProtocolVersions.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", RequestMethods.Initialize); + using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken); + Assert.Equal((int)McpErrorCode.HeaderMismatch, json["error"]!["code"]!.GetValue()); + } + + [Fact] + public async Task InitializeHandshake_StillSucceeds_OnDefaultServer() + { + await StartAsync(); + + var body = @"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"; using var request = new HttpRequestMessage(HttpMethod.Post, "") { Content = JsonContent(body) }; using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); @@ -243,4 +339,3 @@ public async Task GetEndpoint_NotMapped_UnderDefaultStatelessConfiguration_Retur Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); } } - diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs index 807daaefe..73d000797 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/RequestAbortCancellationTests.cs @@ -112,7 +112,7 @@ private static HttpRequestMessage CreateBlockingToolRequest(bool july2026Protoco var body = july2026Protocol ? """ {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"blockingTool","_meta":{"io.modelcontextprotocol/protocolVersion":"PROTOCOL_VERSION","io.modelcontextprotocol/clientInfo":{"name":"raw","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}} - """.Replace("PROTOCOL_VERSION", McpHttpHeaders.July2026ProtocolVersion) + """.Replace("PROTOCOL_VERSION", McpProtocolVersions.July2026ProtocolVersion) : """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"blockingTool"}}"""; var request = new HttpRequestMessage(HttpMethod.Post, "") @@ -125,7 +125,7 @@ private static HttpRequestMessage CreateBlockingToolRequest(bool july2026Protoco if (july2026Protocol) { - request.Headers.Add("MCP-Protocol-Version", McpHttpHeaders.July2026ProtocolVersion); + request.Headers.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "blockingTool"); } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs index 82fb9c020..8aaacc7d6 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs @@ -174,7 +174,7 @@ public async Task RunConformanceTest_HttpHeaderValidation() Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); Assert.SkipWhen( !NodeHelpers.HasSep2243Scenarios(), - "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0)."); + "SEP-2243 conformance scenarios are not available in the installed conformance package."); // SEP-2243 is a 2026-07-28 protocol revision scenario that uses the stateless // lifecycle, so it requires a stateless server (a stateful server rejects the un-initialized list/call @@ -196,7 +196,7 @@ public async Task RunConformanceTest_HttpCustomHeaderServerValidation() Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); Assert.SkipWhen( !NodeHelpers.HasSep2243Scenarios(), - "SEP-2243 conformance scenarios not available (requires conformance package >= 0.2.0)."); + "SEP-2243 conformance scenarios are not available in the installed conformance package."); await using var server = await StatelessConformanceServer.StartAsync( TestContext.Current.CancellationToken, basePort: 3024); @@ -245,7 +245,7 @@ public async Task RunConformanceTest_HttpCustomHeaderServerValidation() public async Task RunMrtrConformanceTest(string scenario) { Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); - Assert.SkipWhen(!NodeHelpers.HasMrtrScenarios(), "SEP-2322 MRTR conformance scenarios not yet available in the published @modelcontextprotocol/conformance package."); + Assert.SkipWhen(!NodeHelpers.HasMrtrScenarios(), "SEP-2322 MRTR conformance scenarios are not available in the installed conformance package."); var result = await RunStatelessConformanceTestAsync( $"server --url {statelessFixture.ServerUrl} --scenario {scenario} --spec-version 2026-07-28"); diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs index 04ebb6492..2bdd9d7e6 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs @@ -951,12 +951,12 @@ public async Task July2026ProtocolVersion_RejectsMissingMcpMethodHeader() // Starting with the 2026-07-28 protocol revision, Streamable HTTP no longer supports sessions (SEP-2567) and is served only on a stateless server. await StartAsync(stateless: true); - // Initialize with the 2026-07-28 protocol version to enable header validation - await CallInitializeWithJuly2026ProtocolVersionAndValidateAsync(); + // Probe with the 2026-07-28 protocol version to enable header validation. + await CallDiscoverWithJuly2026ProtocolVersionAndValidateAsync(); // Send a tools/call request without Mcp-Method header — should be rejected using var request = new HttpRequestMessage(HttpMethod.Post, ""); - request.Content = JsonContent(CallTool("echo", """{"message":"test"}""")); + request.Content = JsonContent(CallTool("echo", """{"message":"test"}""", includePerRequestMetadata: true)); request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); // Deliberately omit Mcp-Method header @@ -968,11 +968,11 @@ public async Task July2026ProtocolVersion_RejectsMissingMcpMethodHeader() public async Task July2026ProtocolVersion_RejectsMismatchedMcpMethodHeader() { await StartAsync(stateless: true); - await CallInitializeWithJuly2026ProtocolVersionAndValidateAsync(); + await CallDiscoverWithJuly2026ProtocolVersionAndValidateAsync(); // Send a tools/call request but set Mcp-Method to wrong value using var request = new HttpRequestMessage(HttpMethod.Post, ""); - request.Content = JsonContent(CallTool("echo", """{"message":"test"}""")); + request.Content = JsonContent(CallTool("echo", """{"message":"test"}""", includePerRequestMetadata: true)); request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "resources/read"); // Wrong method @@ -984,11 +984,11 @@ public async Task July2026ProtocolVersion_RejectsMismatchedMcpMethodHeader() public async Task July2026ProtocolVersion_AcceptsCorrectMcpMethodHeader() { await StartAsync(stateless: true); - await CallInitializeWithJuly2026ProtocolVersionAndValidateAsync(); + await CallDiscoverWithJuly2026ProtocolVersionAndValidateAsync(); // Send a tools/call request with correct Mcp-Method and Mcp-Name headers using var request = new HttpRequestMessage(HttpMethod.Post, ""); - request.Content = JsonContent(CallTool("echo", """{"message":"hello"}""")); + request.Content = JsonContent(CallTool("echo", """{"message":"hello"}""", includePerRequestMetadata: true)); request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); request.Headers.Add("Mcp-Method", "tools/call"); request.Headers.Add("Mcp-Name", "echo"); @@ -998,12 +998,12 @@ public async Task July2026ProtocolVersion_AcceptsCorrectMcpMethodHeader() } [Fact] - public async Task LegacyVersion_DoesNotRequireMcpMethodHeader() + public async Task InitializeHandshakeVersion_DoesNotRequireMcpMethodHeader() { await StartAsync(); await CallInitializeAndValidateAsync(); - // With the legacy version, Mcp-Method header is not required + // With the initialize-handshake version, Mcp-Method header is not required. using var request = new HttpRequestMessage(HttpMethod.Post, ""); request.Content = JsonContent(CallTool("echo", """{"message":"hello"}""")); request.Headers.Add("MCP-Protocol-Version", "2025-03-26"); @@ -1013,25 +1013,25 @@ public async Task LegacyVersion_DoesNotRequireMcpMethodHeader() Assert.Equal(HttpStatusCode.OK, response.StatusCode); } - private async Task CallInitializeWithJuly2026ProtocolVersionAndValidateAsync() + private async Task CallDiscoverWithJuly2026ProtocolVersionAndValidateAsync() { HttpClient.DefaultRequestHeaders.Remove("mcp-session-id"); using var request = new HttpRequestMessage(HttpMethod.Post, ""); - request.Content = JsonContent(InitializeRequestJuly2026Protocol); + request.Content = JsonContent(DiscoverRequestJuly2026Protocol); request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); - request.Headers.Add("Mcp-Method", "initialize"); + request.Headers.Add("Mcp-Method", "server/discover"); using var response = await HttpClient.SendAsync(request, TestContext.Current.CancellationToken); var rpcResponse = await AssertSingleSseResponseAsync(response); - AssertServerInfo(rpcResponse); + AssertDiscoverServerInfo(rpcResponse); - // Starting with the 2026-07-28 protocol revision (SEP-2567), Streamable HTTP no longer supports sessions; the server does not return mcp-session-id. - // Subsequent requests carry MCP-Protocol-Version=2026-07-28 so each one is handled independently. + // Starting with the 2026-07-28 protocol revision, clients use server/discover and per-request + // metadata instead of initialize. } - private static string InitializeRequestJuly2026Protocol => """ - {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"IntegrationTestClient","version":"1.0.0"}}} + private static string DiscoverRequestJuly2026Protocol => """ + {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"IntegrationTestClient","version":"1.0.0"},"io.modelcontextprotocol/clientCapabilities":{}}}} """; #endregion @@ -1103,10 +1103,14 @@ private string Request(string method, string parameters = "{}") """; } - private string CallTool(string toolName, string arguments = "{}") => - Request("tools/call", $$""" - {"name":"{{toolName}}","arguments":{{arguments}}} - """); + private string CallTool(string toolName, string arguments = "{}", bool includePerRequestMetadata = false) + { + var meta = includePerRequestMetadata + ? @",""_meta"":{""io.modelcontextprotocol/protocolVersion"":""2026-07-28"",""io.modelcontextprotocol/clientInfo"":{""name"":""IntegrationTestClient"",""version"":""1.0.0""},""io.modelcontextprotocol/clientCapabilities"":{}}" + : ""; + + return Request("tools/call", "{\"name\":\"" + toolName + "\",\"arguments\":" + arguments + meta + "}"); + } private string CallToolWithProgressToken(string toolName, string arguments = "{}") => Request("tools/call", $$$""" @@ -1126,6 +1130,14 @@ private static InitializeResult AssertServerInfo(JsonRpcResponse rpcResponse) return initializeResult; } + private static DiscoverResult AssertDiscoverServerInfo(JsonRpcResponse rpcResponse) + { + var discoverResult = AssertType(rpcResponse.Result); + Assert.Equal(nameof(StreamableHttpServerConformanceTests), discoverResult.ServerInfo.Name); + Assert.Equal("73", discoverResult.ServerInfo.Version); + return discoverResult; + } + private static CallToolResult AssertEchoResponse(JsonRpcResponse rpcResponse) { var callToolResponse = AssertType(rpcResponse.Result); diff --git a/tests/ModelContextProtocol.ConformanceClient/Program.cs b/tests/ModelContextProtocol.ConformanceClient/Program.cs index 13af5d170..60d07d46e 100644 --- a/tests/ModelContextProtocol.ConformanceClient/Program.cs +++ b/tests/ModelContextProtocol.ConformanceClient/Program.cs @@ -37,11 +37,11 @@ }; // The default client now prefers the 2026-07-28 protocol (probing with server/discover and -// falling back to a legacy initialize handshake). The "initialize" and "sse-retry" scenarios -// specifically exercise the legacy initialize handshake and SSE resumability (removed in the +// falling back to an initialize handshake). The "initialize" and "sse-retry" scenarios +// specifically exercise the initialize handshake and SSE resumability (removed in the // 2026-07-28 protocol) and strictly expect initialize as the first message, so pin them to the // latest stable version. Other scenarios run on the 2026-07-28 default and exercise the -// server/discover probe plus the transparent legacy fallback. +// server/discover probe plus the transparent initialize-handshake fallback. if (scenario is "initialize" or "sse-retry") { options.ProtocolVersion = "2025-11-25"; diff --git a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs index a06548a4f..45d72f15b 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs @@ -9,12 +9,12 @@ namespace ModelContextProtocol.Tests.Client; /// /// Connection-flow tests for the 2026-07-28 protocol revision (SEP-2575 + SEP-2567) /// on . A client that requests -/// calls server/discover rather than +/// calls server/discover rather than /// initialize. /// public class July2026ProtocolConnectionTests : ClientServerTestBase { - private const string LatestStableVersion = "2025-11-25"; + private const string LatestStableVersion = McpProtocolVersions.November2025ProtocolVersion; public July2026ProtocolConnectionTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper, startServer: false) @@ -34,42 +34,40 @@ public async Task Client_RequestingJuly2026Protocol_NegotiatesIt() { StartServer(); - var options = new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }; + var options = new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }; await using var client = await CreateMcpClientForServer(options); - Assert.Equal(McpHttpHeaders.July2026ProtocolVersion, client.NegotiatedProtocolVersion); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion); Assert.NotNull(client.ServerCapabilities); Assert.Equal(nameof(July2026ProtocolConnectionTests), client.ServerInfo.Name); } [Fact] - public async Task Client_RequestingLegacyVersion_NegotiatesLegacy() + public async Task Client_RequestingInitializeHandshakeVersion_NegotiatesIt() { StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); - Assert.NotEqual(McpHttpHeaders.July2026ProtocolVersion, client.NegotiatedProtocolVersion); + Assert.NotEqual(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion); } [Fact] - public async Task LegacyClient_CanCallServerDiscover() + public async Task InitializeHandshakeClient_CannotCallServerDiscover() { - // server/discover is registered unconditionally, so a legacy client can probe it - // (e.g., to learn capabilities without doing a second initialize). + // server/discover is registered unconditionally so the protocol boundary filter can return a structured + // error, but initialize-handshake clients cannot use it after negotiating an older protocol version. StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); - var response = await client.SendRequestAsync( - new JsonRpcRequest { Method = RequestMethods.ServerDiscover }, - TestContext.Current.CancellationToken); + var exception = await Assert.ThrowsAsync(async () => + await client.SendRequestAsync( + new JsonRpcRequest { Method = RequestMethods.ServerDiscover }, + TestContext.Current.CancellationToken)); - var discoverResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); - Assert.NotNull(discoverResult); - Assert.NotEmpty(discoverResult.SupportedVersions); - Assert.Contains(LatestStableVersion, discoverResult.SupportedVersions); - Assert.Equal(nameof(July2026ProtocolConnectionTests), discoverResult.ServerInfo.Name); + Assert.Equal(McpErrorCode.MethodNotFound, exception.ErrorCode); + Assert.Contains(RequestMethods.ServerDiscover, exception.Message, StringComparison.Ordinal); } [Fact] @@ -85,6 +83,6 @@ public async Task ServerDiscover_IncludesJuly2026ProtocolVersion() var discoverResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(discoverResult); - Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, discoverResult.SupportedVersions); + Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], discoverResult.SupportedVersions); } } diff --git a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs index f2bc24cb5..03f8f5b33 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolFallbackTests.cs @@ -8,17 +8,17 @@ namespace ModelContextProtocol.Tests.Client; /// -/// Regression tests for the fallback from the 2026-07-28 protocol revision to a legacy protocol in +/// Regression tests for the fallback from the 2026-07-28 protocol revision to an initialize-handshake protocol in /// . With default options (ProtocolVersion = null) the client prefers -/// 2026-07-28 but probes with server/discover, falls back to the legacy initialize -/// handshake when the server is legacy, and accepts whatever supported protocol version the legacy +/// 2026-07-28 but probes with server/discover, falls back to the initialize +/// handshake when the server only supports that path, and accepts whatever supported protocol version the /// server negotiates. Pinning ProtocolVersion to 2026-07-28 instead makes it the /// minimum too, so the client refuses to fall back. /// /// -/// The originally shipped logic in PerformLegacyInitializeAsync compared the server's response -/// against the requested version and threw when a legacy server downgraded to (say) "2025-06-18", -/// even though the legacy negotiation succeeded. These tests guard against that regression. +/// The originally shipped initialize-handshake fallback logic compared the server's response +/// against the requested version and threw when an initialize-handshake server downgraded to (say) +/// "2025-06-18", even though negotiation succeeded. These tests guard against that regression. /// public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper) { @@ -26,45 +26,66 @@ public class July2026ProtocolFallbackTests(ITestOutputHelper testOutputHelper) : public async Task Client_OnMethodNotFound_FallsBackTo_Initialize_AcceptsDowngradedVersion() { var ct = TestContext.Current.CancellationToken; - await using var transport = new LegacyServerTestTransport(serverNegotiatedVersion: "2025-06-18"); + await using var transport = new InitializeHandshakeServerTestTransport(serverNegotiatedVersion: McpProtocolVersions.June2025ProtocolVersion); // Default options (ProtocolVersion = null) prefer 2026-07-28 but allow automatic fallback. await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), loggerFactory: LoggerFactory, cancellationToken: ct); Assert.True(transport.ServerDiscoverProbed); - Assert.True(transport.LegacyInitializeReceived); - Assert.Equal("2025-06-18", client.NegotiatedProtocolVersion); + Assert.True(transport.InitializeReceived); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, transport.InitializeProtocolVersion); + Assert.Equal(McpProtocolVersions.June2025ProtocolVersion, client.NegotiatedProtocolVersion); } [Fact] public async Task Client_OnInvalidParams_FallsBackTo_Initialize() { var ct = TestContext.Current.CancellationToken; - await using var transport = new LegacyServerTestTransport( - serverNegotiatedVersion: "2025-11-25", + await using var transport = new InitializeHandshakeServerTestTransport( + serverNegotiatedVersion: McpProtocolVersions.November2025ProtocolVersion, probeErrorCode: (int)McpErrorCode.InvalidParams); // Default options (ProtocolVersion = null) prefer 2026-07-28 but allow automatic fallback. await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), loggerFactory: LoggerFactory, cancellationToken: ct); - Assert.True(transport.LegacyInitializeReceived); - Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + Assert.True(transport.InitializeReceived); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, transport.InitializeProtocolVersion); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); } [Fact] - public async Task Client_WithPinnedJuly2026Version_RefusesFallback_ToLegacyServer() + public async Task Client_OnInitializeFallback_RejectsPerRequestMetadataInitializeResponse() { var ct = TestContext.Current.CancellationToken; - await using var transport = new LegacyServerTestTransport(serverNegotiatedVersion: "2025-06-18"); + await using var transport = new InitializeHandshakeServerTestTransport( + serverNegotiatedVersion: McpProtocolVersions.July2026ProtocolVersion); + + var exception = await Assert.ThrowsAnyAsync(async () => + { + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), + loggerFactory: LoggerFactory, cancellationToken: ct); + }); + + Assert.IsType(exception); + Assert.True(transport.InitializeReceived); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, transport.InitializeProtocolVersion); + Assert.Contains("mismatch", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Client_WithPinnedJuly2026Version_RefusesFallback_ToInitializeHandshakeServer() + { + var ct = TestContext.Current.CancellationToken; + await using var transport = new InitializeHandshakeServerTestTransport(serverNegotiatedVersion: McpProtocolVersions.June2025ProtocolVersion); var exception = await Assert.ThrowsAnyAsync(async () => { await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { // Pinning the version makes it the minimum too, so the client refuses to fall back. - ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion, + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, }, loggerFactory: LoggerFactory, cancellationToken: ct); }); @@ -73,17 +94,17 @@ public async Task Client_WithPinnedJuly2026Version_RefusesFallback_ToLegacyServe } [Fact] - public async Task LegacyClient_WithExplicitPin_StillRequires_ExactVersionMatch() + public async Task InitializeHandshakeClient_WithExplicitPin_StillRequires_ExactVersionMatch() { var ct = TestContext.Current.CancellationToken; // Server responds with a DIFFERENT version than the one the user pinned. - await using var transport = new LegacyServerTestTransport(serverNegotiatedVersion: "2025-03-26"); + await using var transport = new InitializeHandshakeServerTestTransport(serverNegotiatedVersion: McpProtocolVersions.March2025ProtocolVersion); var exception = await Assert.ThrowsAnyAsync(async () => { await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { - ProtocolVersion = "2025-11-25", + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }, loggerFactory: LoggerFactory, cancellationToken: ct); }); @@ -94,36 +115,50 @@ public async Task LegacyClient_WithExplicitPin_StillRequires_ExactVersionMatch() [Fact] public async Task Client_OnHeaderMismatch_Surfaces_NoFallback() { - // The peer is modern (returns the spec-defined -32020 HeaderMismatch on the probe). - // Falling back to legacy initialize would just produce another malformed envelope. + // The peer uses per-request metadata (returns the spec-defined -32020 HeaderMismatch on the probe). + // Falling back to initialize would just produce another malformed envelope. // Verify the connect-time logic surfaces the error to the caller instead of falling back. var ct = TestContext.Current.CancellationToken; - await using var transport = new LegacyServerTestTransport( - serverNegotiatedVersion: "2025-11-25", + await using var transport = new InitializeHandshakeServerTestTransport( + serverNegotiatedVersion: McpProtocolVersions.November2025ProtocolVersion, probeErrorCode: (int)McpErrorCode.HeaderMismatch); var exception = await Assert.ThrowsAnyAsync(async () => { await using var client = await McpClient.CreateAsync(transport, new McpClientOptions { - ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion, + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, }, loggerFactory: LoggerFactory, cancellationToken: ct); }); Assert.True(transport.ServerDiscoverProbed); - Assert.False(transport.LegacyInitializeReceived); + Assert.False(transport.InitializeReceived); Assert.Equal(McpErrorCode.HeaderMismatch, ((McpProtocolException)exception).ErrorCode); } + [Fact] + public async Task Client_OnUnsupportedProtocolVersion_WithPerRequestMetadataVersion_RetriesDiscover() + { + var ct = TestContext.Current.CancellationToken; + await using var transport = new PerRequestMetadataRetryTestTransport(); + + await using var client = await McpClient.CreateAsync(transport, new McpClientOptions(), + loggerFactory: LoggerFactory, cancellationToken: ct); + + Assert.Equal(2, transport.ServerDiscoverRequests); + Assert.False(transport.InitializeReceived); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion); + } + [Fact] public async Task Client_OnSilentProbe_FallsBackTo_Initialize_AfterConfiguredProbeTimeout() { - // Simulate a legacy server that silently drops the unknown server/discover method (it never - // responds to the probe). The client must fall back to legacy initialize once the configured + // Simulate an initialize-handshake server that silently drops the unknown server/discover method (it never + // responds to the probe). The client must fall back to initialize once the configured // DiscoverProbeTimeout elapses, well before the much larger InitializationTimeout. var ct = TestContext.Current.CancellationToken; - await using var transport = new LegacyServerTestTransport( - serverNegotiatedVersion: "2025-11-25", + await using var transport = new InitializeHandshakeServerTestTransport( + serverNegotiatedVersion: McpProtocolVersions.November2025ProtocolVersion, silentDiscoverProbe: true); var stopwatch = Stopwatch.StartNew(); @@ -136,8 +171,9 @@ public async Task Client_OnSilentProbe_FallsBackTo_Initialize_AfterConfiguredPro stopwatch.Stop(); Assert.True(transport.ServerDiscoverProbed); - Assert.True(transport.LegacyInitializeReceived); - Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + Assert.True(transport.InitializeReceived); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, transport.InitializeProtocolVersion); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); // The fallback was driven by the short probe timeout, not the 60s InitializationTimeout. Assert.True( @@ -171,23 +207,25 @@ public void DiscoverProbeTimeout_Setter_Accepts_PositiveAndInfiniteValues() } /// - /// Minimal in-memory transport that simulates a legacy server: rejects + /// Minimal in-memory transport that simulates an initialize-handshake server: rejects /// server/discover (with a configurable JSON-RPC error code, or by /// silently dropping the request) and responds to initialize with a /// configurable protocol version. /// - private sealed class LegacyServerTestTransport( + private sealed class InitializeHandshakeServerTestTransport( string serverNegotiatedVersion, int probeErrorCode = (int)McpErrorCode.MethodNotFound, bool silentDiscoverProbe = false) : IClientTransport { private readonly Channel _incomingToClient = Channel.CreateUnbounded(); - public string Name => "legacy-server-test-transport"; + public string Name => "initialize-handshake-server-test-transport"; public bool ServerDiscoverProbed { get; private set; } - public bool LegacyInitializeReceived { get; private set; } + public bool InitializeReceived { get; private set; } + + public string? InitializeProtocolVersion { get; private set; } public Task ConnectAsync(CancellationToken cancellationToken = default) { @@ -205,7 +243,7 @@ private void HandleOutgoingMessage(JsonRpcMessage message) ServerDiscoverProbed = true; if (silentDiscoverProbe) { - // Model a legacy server that drops the unknown method without replying. + // Model an initialize-handshake server that drops the unknown method without replying. break; } @@ -223,7 +261,9 @@ private void HandleOutgoingMessage(JsonRpcMessage message) break; case JsonRpcRequest { Method: RequestMethods.Initialize } initReq: - LegacyInitializeReceived = true; + InitializeReceived = true; + var initializeRequest = JsonSerializer.Deserialize(initReq.Params, McpJsonUtilities.DefaultOptions); + InitializeProtocolVersion = initializeRequest?.ProtocolVersion; _ = WriteAsync(new JsonRpcResponse { Id = initReq.Id, @@ -231,7 +271,7 @@ private void HandleOutgoingMessage(JsonRpcMessage message) { ProtocolVersion = serverNegotiatedVersion, Capabilities = new ServerCapabilities(), - ServerInfo = new Implementation { Name = "legacy-test-server", Version = "1.0.0" }, + ServerInfo = new Implementation { Name = "initialize-handshake-test-server", Version = "1.0.0" }, }, McpJsonUtilities.DefaultOptions), }); break; @@ -243,7 +283,105 @@ private Task WriteAsync(JsonRpcMessage message) private sealed class TransportChannel( Channel incoming, - LegacyServerTestTransport parent) : ITransport + InitializeHandshakeServerTestTransport parent) : ITransport + { + public ChannelReader MessageReader => incoming.Reader; + public bool IsConnected { get; private set; } = true; + public string? SessionId => null; + + public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) + { + parent.HandleOutgoingMessage(message); + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() + { + incoming.Writer.TryComplete(); + IsConnected = false; + return default; + } + } + } + + private sealed class PerRequestMetadataRetryTestTransport : IClientTransport + { + private readonly Channel _incomingToClient = Channel.CreateUnbounded(); + + public string Name => "per-request-metadata-retry-test-transport"; + + public int ServerDiscoverRequests { get; private set; } + + public bool InitializeReceived { get; private set; } + + public Task ConnectAsync(CancellationToken cancellationToken = default) + { + ITransport transport = new TransportChannel(_incomingToClient, this); + return Task.FromResult(transport); + } + + public ValueTask DisposeAsync() => default; + + private void HandleOutgoingMessage(JsonRpcMessage message) + { + switch (message) + { + case JsonRpcRequest { Method: RequestMethods.ServerDiscover } discoverReq: + ServerDiscoverRequests++; + + if (ServerDiscoverRequests == 1) + { + _ = WriteAsync(new JsonRpcError + { + Id = discoverReq.Id, + Error = new JsonRpcErrorDetail + { + Code = (int)McpErrorCode.UnsupportedProtocolVersion, + Message = "Unsupported protocol version", + Data = CreateUnsupportedProtocolVersionData(), + }, + }); + } + else + { + _ = WriteAsync(new JsonRpcResponse + { + Id = discoverReq.Id, + Result = JsonSerializer.SerializeToNode(new DiscoverResult + { + SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion], + Capabilities = new ServerCapabilities(), + ServerInfo = new Implementation { Name = "per-request-metadata-test-server", Version = "1.0.0" }, + }, McpJsonUtilities.DefaultOptions), + }); + } + + break; + + case JsonRpcRequest { Method: RequestMethods.Initialize }: + InitializeReceived = true; + break; + } + } + + private Task WriteAsync(JsonRpcMessage message) + => _incomingToClient.Writer.WriteAsync(message, CancellationToken.None).AsTask(); + + private static JsonElement CreateUnsupportedProtocolVersionData() + { + var json = JsonSerializer.Serialize(new UnsupportedProtocolVersionErrorData + { + Requested = McpProtocolVersions.July2026ProtocolVersion, + Supported = [McpProtocolVersions.July2026ProtocolVersion], + }, McpJsonUtilities.DefaultOptions); + + using var document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } + + private sealed class TransportChannel( + Channel incoming, + PerRequestMetadataRetryTestTransport parent) : ITransport { public ChannelReader MessageReader => incoming.Reader; public bool IsConnected { get; private set; } = true; diff --git a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolListMetaEmissionTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolListMetaEmissionTests.cs index 19ff40287..dd7ba7b29 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolListMetaEmissionTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolListMetaEmissionTests.cs @@ -76,7 +76,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer public async Task Client_ListTools_NoOptions_EmitsRequiredMeta() { StartServer(); - await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); @@ -87,7 +87,7 @@ public async Task Client_ListTools_NoOptions_EmitsRequiredMeta() public async Task Client_ListPrompts_NoOptions_EmitsRequiredMeta() { StartServer(); - await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken); @@ -98,7 +98,7 @@ public async Task Client_ListPrompts_NoOptions_EmitsRequiredMeta() public async Task Client_ListResources_NoOptions_EmitsRequiredMeta() { StartServer(); - await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); @@ -109,7 +109,7 @@ public async Task Client_ListResources_NoOptions_EmitsRequiredMeta() public async Task Client_ListResourceTemplates_NoOptions_EmitsRequiredMeta() { StartServer(); - await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); await client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken); @@ -122,7 +122,7 @@ public async Task Client_ServerDiscover_EmitsRequiredMeta() // server/discover has no public List-style helper; we drive it via SendRequestAsync directly, // which still flows through the client's per-request _meta injector. StartServer(); - await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion }); + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); // Hook the server-side handler invocation via a notification handler is awkward here; assert // instead by sending the request and parsing the wire-shape echo from the response context. @@ -136,7 +136,7 @@ public async Task Client_ServerDiscover_EmitsRequiredMeta() Assert.NotNull(response.Result); var discover = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions)!; - Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, discover.SupportedVersions); + Assert.Contains(McpProtocolVersions.July2026ProtocolVersion, discover.SupportedVersions); // The server enforces the per-request envelope shape; if the client had omitted _meta, the // request would have failed with -32602 / -32021 rather than returning a DiscoverResult. The @@ -144,11 +144,11 @@ public async Task Client_ServerDiscover_EmitsRequiredMeta() } [Fact] - public async Task LegacyClient_ListTools_DoesNotEmitMeta() + public async Task InitializeHandshakeClient_ListTools_DoesNotEmitMeta() { - // Sanity guard: a client on the session-supporting (legacy) protocol must NOT emit the SEP-2575 + // Sanity guard: a client on the session-supporting initialize-handshake protocol must NOT emit the SEP-2575 // envelope. The injector is gated on the negotiated protocol version; if it ever started writing - // those keys on a legacy request, every legacy server would reject it. + // those keys on an initialize-handshake request, every initialize-handshake server would reject it. StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); @@ -175,6 +175,6 @@ private void AssertRequiredMetaPresent(string method) $"Missing clientCapabilities key on {method} _meta envelope"); // The protocolVersion value must match the negotiated 2026-07-28 protocol version. - Assert.Equal(McpHttpHeaders.July2026ProtocolVersion, meta[MetaKeys.ProtocolVersion]!.GetValue()); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, meta[MetaKeys.ProtocolVersion]!.GetValue()); } } diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs index 42af028b2..48f4e66e1 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs @@ -178,7 +178,7 @@ public virtual Task SendMessageAsync(JsonRpcMessage message, CancellationToken c Result = JsonSerializer.SerializeToNode(new DiscoverResult { Capabilities = new ServerCapabilities(), - SupportedVersions = [McpHttpHeaders.July2026ProtocolVersion], + SupportedVersions = [McpProtocolVersions.July2026ProtocolVersion], ServerInfo = new Implementation { Name = "NopTransport", diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs index 4dda7bc38..aefe6a962 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs @@ -483,7 +483,7 @@ private sealed class SynchronousProgress(Action callb [Fact] public async Task AsClientLoggerProvider_MessagesSentToClient() { - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }); ILoggerProvider loggerProvider = Server.AsClientLoggerProvider(); Assert.Throws("categoryName", () => loggerProvider.CreateLogger(null!)); @@ -765,7 +765,7 @@ await Assert.ThrowsAsync("requestParams", [Fact] public async Task SetLoggingLevelAsync_WithRequestParams_SetsLevel() { - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }); // Should not throw await client.SetLoggingLevelAsync( @@ -795,9 +795,9 @@ await Assert.ThrowsAsync("requestParams", [Fact] public async Task ServerCanPingClient() { - // ping is a legacy-only RPC (removed in the 2026-07-28 protocol per SEP-2575), so pin the client - // to a legacy protocol version to exercise the server-initiated ping round-trip. - await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = "2025-11-25" }); + // ping is available only on initialize-handshake revisions (removed in the 2026-07-28 protocol + // per SEP-2575), so pin the client to exercise the server-initiated ping round-trip. + await using McpClient client = await CreateMcpClientForServer(new() { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }); var pingRequest = new JsonRpcRequest { Method = RequestMethods.Ping }; var response = await Server.SendRequestAsync(pingRequest, TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs b/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs index 5868c3c63..be629ecd2 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpRequestHeadersTests.cs @@ -28,8 +28,9 @@ public void McpErrorCode_HeaderMismatch_HasCorrectValue() [InlineData("2024-11-05", false)] [InlineData(null, false)] [InlineData("", false)] - public void SupportsStandardHeaders_ReturnsExpected(string? version, bool expected) + public void RequiresStandardHeaders_ReturnsExpected(string? version, bool expected) { - Assert.Equal(expected, McpHttpHeaders.SupportsStandardHeaders(version)); + Assert.Equal(expected, McpProtocolVersions.RequiresStandardHeaders(version)); } + } diff --git a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs index 7690a75f9..9c63f12ce 100644 --- a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs @@ -33,10 +33,10 @@ public async Task ConnectAndPing_Stdio(string clientId) // Arrange // Act - // ping was removed in the 2026-07-28 protocol (SEP-2575), so pin to the latest stable - // protocol version to keep exercising the legacy ping RPC. The 2026-07-28 protocol relies on - // the transport/request lifecycle instead of an explicit ping. - await using var client = await _fixture.CreateClientAsync(clientId, new McpClientOptions { ProtocolVersion = "2025-11-25" }); + // ping was removed in the 2026-07-28 protocol (SEP-2575), so pin to the latest + // initialize-handshake revision to keep exercising the ping RPC. The 2026-07-28 protocol + // relies on the transport/request lifecycle instead of an explicit ping. + await using var client = await _fixture.CreateClientAsync(clientId, new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }); await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken); // Assert @@ -558,6 +558,7 @@ public async Task SetLoggingLevel_ReceivesLoggingMessages(string clientId) TaskCompletionSource receivedNotification = new(); await using var client = await _fixture.CreateClientAsync(clientId, new() { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, Handlers = new() { NotificationHandlers = diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs index 03234f6e3..96647fab2 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs @@ -130,10 +130,10 @@ public async Task Can_Be_Notified_Of_Prompt_Changes() { // Under the 2026-07-28 protocol, list-changed notifications are delivered only over a // subscriptions/listen stream (covered by SubscriptionsListenTests). This test pins the - // legacy revision to keep coverage of the session-wide broadcast that legacy clients still rely on. + // initialize-handshake revision to keep coverage of the session-wide broadcast that older clients still rely on. await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }); @@ -180,7 +180,7 @@ public async Task DeferChangedEvents_BatchAddPrompts_EmitsExactlyOneNotification // subscriptions/listen stream. Pin the legacy revision to test the session-wide broadcast. await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }); var serverOptions = ServiceProvider.GetRequiredService>().Value; diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsRequestFilterTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsRequestFilterTests.cs index 0c4783b28..d3de3a23e 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsRequestFilterTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsRequestFilterTests.cs @@ -280,7 +280,7 @@ public async Task AddUnsubscribeFromResourcesFilter_Logs_When_UnsubscribeFromRes [Fact] public async Task AddSetLoggingLevelFilter_Logs_When_SetLoggingLevel_Called() { - await using McpClient client = await CreateMcpClientForServer(); + await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }); await client.SetLoggingLevelAsync(LoggingLevel.Info, cancellationToken: TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs index 663c06a7f..ff3b9114a 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs @@ -164,10 +164,10 @@ public async Task Can_Be_Notified_Of_Resource_Changes() { // Under the 2026-07-28 protocol, list-changed notifications are delivered only over a // subscriptions/listen stream (covered by SubscriptionsListenTests). This test pins the - // legacy revision to keep coverage of the session-wide broadcast that legacy clients still rely on. + // initialize-handshake revision to keep coverage of the session-wide broadcast that older clients still rely on. await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }); @@ -214,7 +214,7 @@ public async Task DeferChangedEvents_BatchAddResources_EmitsExactlyOneNotificati // subscriptions/listen stream. Pin the legacy revision to test the session-wide broadcast. await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }); var serverOptions = ServiceProvider.GetRequiredService>().Value; diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs index 154c40f94..a05084a86 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs @@ -190,10 +190,10 @@ public async Task Can_Be_Notified_Of_Tool_Changes() { // Under the 2026-07-28 protocol, list-changed notifications are delivered only over a // subscriptions/listen stream (covered by SubscriptionsListenTests). This test pins the - // legacy revision to keep coverage of the session-wide broadcast that legacy clients still rely on. + // initialize-handshake revision to keep coverage of the session-wide broadcast that older clients still rely on. await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }); var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); @@ -239,7 +239,7 @@ public async Task DeferChangedEvents_BatchAddTools_EmitsExactlyOneNotification() // subscriptions/listen stream. Pin the legacy revision to test the session-wide broadcast. await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }); var serverOptions = ServiceProvider.GetRequiredService>().Value; diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index 4b782cd64..973ca4cb6 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -36,6 +36,7 @@ + diff --git a/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs index 14f0d497e..4a5873df6 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/CacheableResultWarningTests.cs @@ -43,7 +43,7 @@ public async Task DraftServerOmittingBothHints_LogsWarning(string method) { var (call, result) = GetScenario(method, ttl: null, scope: null); - await RunScenarioAsync(July2026ProtocolVersion, useModernLifecycle: true, method, result, call, TestContext.Current.CancellationToken); + await RunScenarioAsync(July2026ProtocolVersion, usePerRequestMetadataLifecycle: true, method, result, call, TestContext.Current.CancellationToken); var warning = Assert.Single(MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Warning && m.Message.Contains(method) && m.Message.Contains("SEP-2549")); @@ -56,7 +56,7 @@ public async Task DraftServerOmittingOnlyCacheScope_WarnsAboutCacheScope() { var (call, result) = GetScenario(RequestMethods.ToolsList, ttl: TimeSpan.FromMinutes(5), scope: null); - await RunScenarioAsync(July2026ProtocolVersion, useModernLifecycle: true, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken); + await RunScenarioAsync(July2026ProtocolVersion, usePerRequestMetadataLifecycle: true, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken); var warning = Assert.Single(MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Warning && m.Message.Contains("SEP-2549")); @@ -69,7 +69,7 @@ public async Task DraftServerProvidingBothHints_DoesNotWarn() { var (call, result) = GetScenario(RequestMethods.ToolsList, ttl: TimeSpan.FromMinutes(5), scope: CacheScope.Public); - await RunScenarioAsync(July2026ProtocolVersion, useModernLifecycle: true, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken); + await RunScenarioAsync(July2026ProtocolVersion, usePerRequestMetadataLifecycle: true, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken); Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Warning && m.Message.Contains("SEP-2549")); @@ -81,7 +81,7 @@ public async Task OlderServerOmittingHints_DoesNotWarn() // A server on an older protocol version may legitimately omit the fields; no warning should fire. var (call, result) = GetScenario(RequestMethods.ToolsList, ttl: null, scope: null); - await RunScenarioAsync(OlderProtocolVersion, useModernLifecycle: false, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken); + await RunScenarioAsync(OlderProtocolVersion, usePerRequestMetadataLifecycle: false, RequestMethods.ToolsList, result, call, TestContext.Current.CancellationToken); Assert.DoesNotContain(MockLoggerProvider.LogMessages, m => m.LogLevel == LogLevel.Warning && m.Message.Contains("SEP-2549")); @@ -98,7 +98,7 @@ public async Task AutoPaginatingOverload_DraftServerOmittingHints_LogsWarning() await RunScenarioAsync( July2026ProtocolVersion, - useModernLifecycle: true, + usePerRequestMetadataLifecycle: true, RequestMethods.ToolsList, result, (c, ct) => c.ListToolsAsync(cancellationToken: ct).AsTask(), @@ -130,7 +130,7 @@ public async Task AutoPaginatingOverload_MultiplePages_WarnsOnlyOncePerMethod() var serverReader = new StreamReader(clientToServer.Reader.AsStream()); var serverWriter = serverToClient.Writer.AsStream(); - await PerformHandshakeAsync(serverReader, serverWriter, July2026ProtocolVersion, useModernLifecycle: true, TestContext.Current.CancellationToken); + await PerformHandshakeAsync(serverReader, serverWriter, July2026ProtocolVersion, usePerRequestMetadataLifecycle: true, TestContext.Current.CancellationToken); await using var client = await clientTask; @@ -208,7 +208,7 @@ private static (Func Call, JsonNode Result) private async Task RunScenarioAsync( string serverProtocolVersion, - bool useModernLifecycle, + bool usePerRequestMetadataLifecycle, string method, JsonNode resultNode, Func clientCall, @@ -217,8 +217,8 @@ private async Task RunScenarioAsync( var clientToServer = new Pipe(); var serverToClient = new Pipe(); - // Pin the protocol version so the client deterministically takes the modern (server/discover) - // lifecycle for 2026-07-28 and the legacy (initialize) lifecycle for older versions. + // Pin the protocol version so the client deterministically takes the per-request metadata + // (server/discover) lifecycle for 2026-07-28 and the initialize lifecycle for older versions. var clientTask = McpClient.CreateAsync( new StreamClientTransport( clientToServer.Writer.AsStream(), @@ -231,7 +231,7 @@ private async Task RunScenarioAsync( var serverReader = new StreamReader(clientToServer.Reader.AsStream()); var serverWriter = serverToClient.Writer.AsStream(); - await PerformHandshakeAsync(serverReader, serverWriter, serverProtocolVersion, useModernLifecycle, cancellationToken); + await PerformHandshakeAsync(serverReader, serverWriter, serverProtocolVersion, usePerRequestMetadataLifecycle, cancellationToken); await using var client = await clientTask; Assert.Equal(serverProtocolVersion, client.NegotiatedProtocolVersion); @@ -271,7 +271,7 @@ private static async Task PerformHandshakeAsync( StreamReader serverReader, Stream serverWriter, string serverProtocolVersion, - bool useModernLifecycle, + bool usePerRequestMetadataLifecycle, CancellationToken cancellationToken) { var requestLine = await serverReader.ReadLineAsync(cancellationToken); @@ -279,9 +279,9 @@ private static async Task PerformHandshakeAsync( var request = JsonSerializer.Deserialize(requestLine, McpJsonUtilities.DefaultOptions); Assert.NotNull(request); - if (useModernLifecycle) + if (usePerRequestMetadataLifecycle) { - // Modern 2026-07-28 lifecycle (SEP-2575): no initialize handshake. The client probes + // Per-request metadata lifecycle (SEP-2575): no initialize handshake. The client probes // server/discover to learn capabilities, then sends normal RPCs carrying per-request _meta. Assert.Equal(RequestMethods.ServerDiscover, request.Method); @@ -298,7 +298,7 @@ private static async Task PerformHandshakeAsync( } else { - // Legacy initialize handshake for older protocol versions. + // Initialize handshake for older protocol versions. Assert.Equal(RequestMethods.Initialize, request.Method); await WriteJsonRpcAsync(serverWriter, new JsonRpcResponse diff --git a/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs b/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs index 248bf7768..4a2d7e6df 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/DiscoverResultCacheableTests.cs @@ -17,7 +17,7 @@ public static class DiscoverResultCacheableTests { private static DiscoverResult NewDiscoverResult() => new() { - SupportedVersions = [McpHttpHeaders.November2025ProtocolVersion, McpHttpHeaders.July2026ProtocolVersion], + SupportedVersions = [McpProtocolVersions.November2025ProtocolVersion, McpProtocolVersions.July2026ProtocolVersion], Capabilities = new ServerCapabilities(), ServerInfo = new Implementation { Name = "test-server", Version = "1.0" }, }; diff --git a/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs index 4963a0a10..8d97eb52d 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/UrlElicitationTests.cs @@ -196,7 +196,7 @@ await request.Server.ElicitAsync(new() // capability behavior under the 2026-07-28 revision is covered by McpClientMetaTests. private Task CreateLegacyClientForServer(McpClientOptions clientOptions) { - clientOptions.ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion; + clientOptions.ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion; return CreateMcpClientForServer(clientOptions); } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index 87f363f03..c872f9644 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -26,7 +26,7 @@ private static McpServerOptions CreateOptions(ServerCapabilities? capabilities = { return new McpServerOptions { - ProtocolVersion = "2024", + ProtocolVersion = "2024-11-05", InitializationTimeout = TimeSpan.FromSeconds(30), Capabilities = capabilities, }; @@ -285,11 +285,92 @@ await Can_Handle_Requests( Assert.NotNull(result); Assert.Equal(expectedAssemblyName.Name, result.ServerInfo.Name); Assert.Equal(expectedAssemblyName.Version?.ToString() ?? "1.0.0", result.ServerInfo.Version); - Assert.Equal("2024", result.ProtocolVersion); - Assert.Equal("2024", server.NegotiatedProtocolVersion); + Assert.Equal("2024-11-05", result.ProtocolVersion); + Assert.Equal("2024-11-05", server.NegotiatedProtocolVersion); }); } + [Fact] + public async Task RejectedReservedPerRequestMetadata_DoesNotEstablishProtocolVersion() + { + var ct = TestContext.Current.CancellationToken; + await using var transport = new TestServerTransport(); + var options = CreateOptions(); + options.ProtocolVersion = null; + + await using var server = McpServer.Create(transport, options, LoggerFactory); + var runTask = server.RunAsync(ct); + + var rejectedResponse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var acceptedResponse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + transport.OnMessageSent = message => + { + if (message is JsonRpcError { Id: var errorId } error && errorId.ToString() == "1") + { + rejectedResponse.TrySetResult(error); + } + else if (message is JsonRpcMessageWithId { Id: var responseId } && responseId.ToString() == "2") + { + acceptedResponse.TrySetResult(message); + } + }; + + await transport.SendClientMessageAsync(new JsonRpcRequest + { + Id = new RequestId(1), + Method = RequestMethods.ToolsList, + Params = new JsonObject + { + ["_meta"] = new JsonObject + { + [MetaKeys.ClientInfo] = new JsonObject + { + ["name"] = "test-client", + ["version"] = "1.0.0", + }, + }, + }, + Context = new JsonRpcMessageContext + { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, + ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" }, + }, + }, ct); + + var error = await rejectedResponse.Task.WaitAsync(TestConstants.DefaultTimeout, ct); + Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code); + Assert.Null(server.NegotiatedProtocolVersion); + + var clientInfo = new Implementation { Name = "test-client", Version = "1.0.0" }; + var clientCapabilities = new ClientCapabilities(); + await transport.SendClientMessageAsync(new JsonRpcRequest + { + Id = new RequestId(2), + Method = RequestMethods.ToolsList, + Params = new JsonObject + { + ["_meta"] = new JsonObject + { + [MetaKeys.ProtocolVersion] = McpProtocolVersions.July2026ProtocolVersion, + [MetaKeys.ClientInfo] = JsonSerializer.SerializeToNode(clientInfo, McpJsonUtilities.DefaultOptions), + [MetaKeys.ClientCapabilities] = new JsonObject(), + }, + }, + Context = new JsonRpcMessageContext + { + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, + ClientInfo = clientInfo, + ClientCapabilities = clientCapabilities, + }, + }, ct); + + await acceptedResponse.Task.WaitAsync(TestConstants.DefaultTimeout, ct); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, server.NegotiatedProtocolVersion); + + await transport.DisposeAsync(); + await runTask; + } + [Fact] public async Task Initialize_IncludesExtensionsInResponse() { diff --git a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs index 29ae7823f..d3e6f5e69 100644 --- a/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/NegotiatedProtocolVersionTests.cs @@ -54,38 +54,224 @@ public async Task PerRequestProtocolVersion_IsEstablishedOnce_AndRejectsLaterCha var ct = TestContext.Current.CancellationToken; // The first request establishes the 2026-07-28 version for the stateful session (null -> 2026-07-28). - Assert.IsType(await RoundTripAsync(id: 1, McpHttpHeaders.July2026ProtocolVersion, ct)); + Assert.IsType(await RoundTripAsync(id: 1, McpProtocolVersions.July2026ProtocolVersion, ct)); // Re-sending the same version is an idempotent no-op, not an error. - Assert.IsType(await RoundTripAsync(id: 2, McpHttpHeaders.July2026ProtocolVersion, ct)); + Assert.IsType(await RoundTripAsync(id: 2, McpProtocolVersions.July2026ProtocolVersion, ct)); // Switching to a different (still-supported) version mid-session is rejected. - var error = Assert.IsType(await RoundTripAsync(id: 3, McpHttpHeaders.November2025ProtocolVersion, ct)); + var error = Assert.IsType(await RoundTripAsync(id: 3, McpProtocolVersions.November2025ProtocolVersion, ct)); Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code); Assert.Contains("protocol version cannot change", error.Error.Message, StringComparison.OrdinalIgnoreCase); // The rejected request must not have mutated the negotiated version: the original 2026-07-28 version still works. - Assert.IsType(await RoundTripAsync(id: 4, McpHttpHeaders.July2026ProtocolVersion, ct)); + Assert.IsType(await RoundTripAsync(id: 4, McpProtocolVersions.July2026ProtocolVersion, ct)); } - private async Task RoundTripAsync(long id, string protocolVersion, CancellationToken cancellationToken) + [Fact] + public async Task PerRequestMetadata_RejectsInitializeHandshakeVersionBeforeInitialize() + { + var ct = TestContext.Current.CancellationToken; + + var error = Assert.IsType(await RoundTripAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, error.Error.Code); + Assert.Contains("initialize", error.Error.Message, StringComparison.OrdinalIgnoreCase); + + // The rejected initialize-handshake _meta request must not have established session state. + Assert.IsType(await RoundTripAsync(id: 2, McpProtocolVersions.July2026ProtocolVersion, ct)); + } + + [Fact] + public async Task PerRequestMetadata_RejectsRequestMissingRequiredMetadata() + { + var ct = TestContext.Current.CancellationToken; + + var error = Assert.IsType( + await RoundTripAsync( + id: 1, + McpProtocolVersions.July2026ProtocolVersion, + ct, + includeClientInfo: false)); + + Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); + Assert.Contains(MetaKeys.ClientInfo, error.Error.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ServerDiscover_WithoutPerRequestMetadata_IsRejectedBeforeInitialize() + { + var ct = TestContext.Current.CancellationToken; + + var request = new JsonRpcRequest + { + Id = new RequestId(1), + Method = RequestMethods.ServerDiscover, + Params = new JsonObject(), + }; + + var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Equal((int)McpErrorCode.InvalidParams, error.Error.Code); + Assert.Contains(RequestMethods.ServerDiscover, error.Error.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task Initialize_WithPerRequestMetadataProtocolVersion_IsRejected() + { + var ct = TestContext.Current.CancellationToken; + + var error = Assert.IsType( + await RoundTripInitializeAsync(id: 1, McpProtocolVersions.July2026ProtocolVersion, ct)); + + Assert.Equal((int)McpErrorCode.UnsupportedProtocolVersion, error.Error.Code); + Assert.Contains("initialize", error.Error.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Initialize_WithReservedPerRequestMetadata_IsRejected() + { + var ct = TestContext.Current.CancellationToken; + + var request = new JsonRpcRequest + { + Id = new RequestId(1), + Method = RequestMethods.Initialize, + Params = JsonSerializer.SerializeToNode(new InitializeRequestParams + { + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, + Capabilities = new ClientCapabilities(), + ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" }, + Meta = new JsonObject + { + [MetaKeys.ClientInfo] = new JsonObject + { + ["name"] = "per-request-meta-client", + ["version"] = "1.0.0", + }, + }, + }, McpJsonUtilities.DefaultOptions), + }; + + var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Equal((int)McpErrorCode.InvalidRequest, error.Error.Code); + Assert.Contains(MetaKeys.ClientInfo, error.Error.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task SubscriptionsListen_WithInitializeProtocolVersion_IsRejected() + { + var ct = TestContext.Current.CancellationToken; + + Assert.IsType( + await RoundTripInitializeAsync(id: 1, McpProtocolVersions.November2025ProtocolVersion, ct)); + + var request = new JsonRpcRequest + { + Id = new RequestId(2), + Method = RequestMethods.SubscriptionsListen, + Params = new JsonObject(), + }; + + var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Equal((int)McpErrorCode.MethodNotFound, error.Error.Code); + Assert.Contains(RequestMethods.SubscriptionsListen, error.Error.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task LoggingSetLevel_WithPerRequestMetadataProtocolVersion_IsRejected() { - // tools/list is available under both the legacy and 2026-07-28 revisions (unlike ping/initialize, + var ct = TestContext.Current.CancellationToken; + + var request = new JsonRpcRequest + { + Id = new RequestId(1), + Method = RequestMethods.LoggingSetLevel, + Params = new JsonObject + { + ["level"] = "info", + ["_meta"] = PerRequestMetadata(), + }, + }; + + var error = Assert.IsType(await SendAndReceiveAsync(request, ct)); + Assert.Equal((int)McpErrorCode.MethodNotFound, error.Error.Code); + Assert.Contains(RequestMethods.LoggingSetLevel, error.Error.Message, StringComparison.Ordinal); + } + + private async Task RoundTripAsync( + long id, + string protocolVersion, + CancellationToken cancellationToken, + bool includeClientInfo = true, + bool includeClientCapabilities = true) + { + // tools/list is available under both the initialize-handshake and 2026-07-28 revisions (unlike ping/initialize, // which the 2026-07-28 protocol removed), so it exercises the version guard rather than the // per-method availability gate. + var meta = new JsonObject + { + [MetaKeys.ProtocolVersion] = protocolVersion, + }; + + if (McpProtocolVersions.RequiresPerRequestMetadata(protocolVersion)) + { + if (includeClientInfo) + { + meta[MetaKeys.ClientInfo] = new JsonObject + { + ["name"] = "test-client", + ["version"] = "1.0.0", + }; + } + + if (includeClientCapabilities) + { + meta[MetaKeys.ClientCapabilities] = new JsonObject(); + } + } + var request = new JsonRpcRequest { Id = new RequestId(id), Method = RequestMethods.ToolsList, Params = new JsonObject { - ["_meta"] = new JsonObject - { - [MetaKeys.ProtocolVersion] = protocolVersion, - }, + ["_meta"] = meta, }, }; + return await SendAndReceiveAsync(request, cancellationToken); + } + + private static JsonObject PerRequestMetadata() => new() + { + [MetaKeys.ProtocolVersion] = McpProtocolVersions.July2026ProtocolVersion, + [MetaKeys.ClientInfo] = new JsonObject + { + ["name"] = "test-client", + ["version"] = "1.0.0", + }, + [MetaKeys.ClientCapabilities] = new JsonObject(), + }; + + private async Task RoundTripInitializeAsync(long id, string protocolVersion, CancellationToken cancellationToken) + { + var request = new JsonRpcRequest + { + Id = new RequestId(id), + Method = RequestMethods.Initialize, + Params = JsonSerializer.SerializeToNode(new InitializeRequestParams + { + ProtocolVersion = protocolVersion, + Capabilities = new ClientCapabilities(), + ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" }, + }, McpJsonUtilities.DefaultOptions), + }; + + return await SendAndReceiveAsync(request, cancellationToken); + } + + private async Task SendAndReceiveAsync(JsonRpcRequest request, CancellationToken cancellationToken) + { string json = JsonSerializer.Serialize(request, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage))); #if NET await _writer.WriteLineAsync(json.AsMemory(), cancellationToken); diff --git a/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs b/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs index 9e5a9e11f..d5735f105 100644 --- a/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/PingProtocolGatingTests.cs @@ -8,8 +8,8 @@ namespace ModelContextProtocol.Tests.Server; /// /// Verifies that the built-in ping handler is gated by protocol version. /// SEP-2575 (the 2026-07-28 revision) removes ping; servers must -/// respond with -32601 MethodNotFound. Legacy protocol versions still -/// support ping per the spec. +/// respond with -32601 MethodNotFound. Initialize-handshake protocol +/// versions still support ping per the spec. /// public sealed class PingProtocolGatingTests : ClientServerTestBase { @@ -28,7 +28,7 @@ public async Task Ping_OnJuly2026ProtocolSession_ReturnsMethodNotFound() StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion, + ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion, }); var ex = await Assert.ThrowsAsync(async () => @@ -38,13 +38,13 @@ public async Task Ping_OnJuly2026ProtocolSession_ReturnsMethodNotFound() } [Fact] - public async Task Ping_OnLegacySession_StillSucceeds() + public async Task Ping_OnInitializeHandshakeSession_StillSucceeds() { // Default server config; client pinned to 2025-11-25. StartServer(); await using var client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = "2025-11-25", + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }); var result = await client.PingAsync(cancellationToken: TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs index 818bd2b4b..5ece0f6b1 100644 --- a/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs @@ -76,7 +76,7 @@ private async Task ReadAsync() return JsonNode.Parse(line!)!; } - private static string July2026ProtocolMetaFragment(string protocolVersion = McpHttpHeaders.July2026ProtocolVersion) => + private static string July2026ProtocolMetaFragment(string protocolVersion = McpProtocolVersions.July2026ProtocolVersion) => @"""_meta"":{""io.modelcontextprotocol/protocolVersion"":""" + protocolVersion + @""",""io.modelcontextprotocol/clientInfo"":{""name"":""raw"",""version"":""1.0""}," + @"""io.modelcontextprotocol/clientCapabilities"":{}}"; @@ -96,7 +96,7 @@ public async Task ServerDiscover_ReturnsSupportedVersionsIncludingJuly2026Protoc var supportedVersions = result!["supportedVersions"]!.AsArray() .Select(n => n!.GetValue()) .ToList(); - Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, supportedVersions); + Assert.Contains(McpProtocolVersions.July2026ProtocolVersion, supportedVersions); // Capabilities and serverInfo are mandatory in DiscoverResult per SEP-2575. Assert.NotNull(result["capabilities"]); @@ -146,15 +146,15 @@ await SendAsync( Assert.NotNull(data); Assert.Equal("9999-99-99", data!["requested"]!.GetValue()); var supported = data["supported"]!.AsArray().Select(n => n!.GetValue()).ToList(); - Assert.Contains(McpHttpHeaders.July2026ProtocolVersion, supported); + Assert.Contains(McpProtocolVersions.July2026ProtocolVersion, supported); } [Fact] - public async Task LegacyInitialize_StillWorks_OnJuly2026ProtocolDefaultServer() + public async Task InitializeHandshake_StillWorks_OnJuly2026ProtocolDefaultServer() { - // Dual-era: a server defaulting to the 2026-07-28 protocol (ProtocolVersion = McpHttpHeaders.July2026ProtocolVersion in McpServerOptions) must still - // accept the legacy initialize handshake from clients that don't speak the new protocol. - await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""version"":""1.0""}}}"); + // Dual-path: a default server must still accept the initialize handshake from clients that + // don't speak the 2026-07-28 per-request metadata path. + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"); var response = await ReadAsync(); Assert.Equal(1, response["id"]!.GetValue()); @@ -166,13 +166,14 @@ public async Task LegacyInitialize_StillWorks_OnJuly2026ProtocolDefaultServer() [Fact] public async Task MixedSequence_Discover_Then_Initialize_Then_ToolsCall_AllSucceed() { - // Dual-era servers must accept 2026-07-28 and legacy traffic on the same connection. The exact mix below - // is what a permissive client running against an unknown server would emit while probing. + // Dual-path servers must accept 2026-07-28 per-request metadata and initialize-handshake traffic + // on the same connection. The exact mix below is what a permissive client running against an unknown + // server would emit while probing. await SendAsync(@"{""jsonrpc"":""2.0"",""id"":1,""method"":""server/discover"",""params"":{" + July2026ProtocolMetaFragment() + "}}"); var discover = await ReadAsync(); Assert.NotNull(discover["result"]); - await SendAsync(@"{""jsonrpc"":""2.0"",""id"":2,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""legacy"",""version"":""1.0""}}}"); + await SendAsync(@"{""jsonrpc"":""2.0"",""id"":2,""method"":""initialize"",""params"":{""protocolVersion"":""2025-11-25"",""capabilities"":{},""clientInfo"":{""name"":""initialize-handshake"",""version"":""1.0""}}}"); var init = await ReadAsync(); Assert.NotNull(init["result"]); Assert.Equal("2025-11-25", init["result"]!["protocolVersion"]!.GetValue()); diff --git a/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenTests.cs b/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenTests.cs index d0dd2a796..d727e00f7 100644 --- a/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/SubscriptionsListenTests.cs @@ -15,7 +15,7 @@ namespace ModelContextProtocol.Tests.Server; /// End-to-end tests for the SEP-2575 subscriptions/listen list-changed delivery over an /// in-memory stream transport (the stdio-shaped path exercised by ). /// Validates that a client on the 2026-07-28 protocol receives only the change notifications it subscribed to, each tagged -/// with the subscription id, and that legacy sessions keep receiving the session-wide broadcast. +/// with the subscription id, and that initialize-handshake sessions keep receiving the session-wide broadcast. /// public class SubscriptionsListenTests : ClientServerTestBase { @@ -104,11 +104,11 @@ public async Task July2026Protocol_WithoutSubscription_DoesNotBroadcastListChang } [Fact] - public async Task Legacy_ListChanged_IsBroadcast_WithoutSubscription() + public async Task InitializeHandshake_ListChanged_IsBroadcast_WithoutSubscription() { await using McpClient client = await CreateMcpClientForServer(new McpClientOptions { - ProtocolVersion = McpHttpHeaders.November2025ProtocolVersion, + ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion, }); var toolsChannel = Channel.CreateUnbounded(); @@ -118,7 +118,7 @@ public async Task Legacy_ListChanged_IsBroadcast_WithoutSubscription() var serverOptions = ServiceProvider.GetRequiredService>().Value; serverOptions.ToolCollection!.Add(McpServerTool.Create([McpServerTool(Name = "AddedTool")] () => "42")); - // Legacy sessions keep the session-wide broadcast and the notification carries no subscription id. + // Initialize-handshake sessions keep the session-wide broadcast and the notification carries no subscription id. var notification = await toolsChannel.Reader.ReadAsync(TestContext.Current.CancellationToken); Assert.Null(GetSubscriptionId(notification)); } diff --git a/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs index 7f9790526..2f1974ce0 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskProtocolGatingTests.cs @@ -127,24 +127,23 @@ public async Task LegacyClient_CallToolRaw_ReturnsDirectResult_NoTaskCreated() } [Fact] - public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_ServerReturnsDirectResult() + public async Task LegacyClient_CallToolRaw_WithForgedTaskOptIn_RejectsReservedMetadata() { await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = LatestStableVersion }); var ct = TestContext.Current.CancellationToken; // Forge a SEP-2575 capabilities envelope carrying the tasks extension opt-in on a legacy - // request. The server must still refuse to create a task because the per-request protocol - // version is not the 2026-07-28 protocol. - var result = await client.CallToolRawAsync( + // request. The server rejects reserved per-request metadata before it can affect behavior. + var ex = await Assert.ThrowsAsync(async () => await client.CallToolRawAsync( new CallToolRequestParams { Name = "test-tool", Arguments = CreateArguments("input", "forged"), Meta = CreateForgedTaskOptInMeta(), - }, ct); + }, ct)); - Assert.False(result.IsTask); - Assert.NotNull(result.Result); + Assert.Equal(McpErrorCode.InvalidRequest, ex.ErrorCode); + Assert.Contains(ClientCapabilitiesMetaKey, ex.Message); } [Fact] From 374e76eecdfd83b4c7ea363f1303821d6aa11ef7 Mon Sep 17 00:00:00 2001 From: Pranav Senthilnathan Date: Sun, 12 Jul 2026 21:24:21 -0400 Subject: [PATCH 15/16] Fix missing resultType on complete result responses (#1684) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Tarek Mahmoud Sayed --- .../Protocol/EmptyResult.cs | 2 +- .../Protocol/Result.cs | 2 +- .../Server/McpServerImpl.cs | 40 ++++++++++++++- .../Client/July2026ProtocolConnectionTests.cs | 1 + .../Server/McpServerTaskTests.cs | 28 +++++++++++ .../Server/McpServerTests.cs | 49 +++++++++++++++++++ 6 files changed, 118 insertions(+), 4 deletions(-) diff --git a/src/ModelContextProtocol.Core/Protocol/EmptyResult.cs b/src/ModelContextProtocol.Core/Protocol/EmptyResult.cs index cf26cc3d5..6bf9d633d 100644 --- a/src/ModelContextProtocol.Core/Protocol/EmptyResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/EmptyResult.cs @@ -9,5 +9,5 @@ namespace ModelContextProtocol.Protocol; public sealed class EmptyResult : Result { [JsonIgnore] - internal static EmptyResult Instance { get; } = new(); + internal static EmptyResult Instance { get; } = new() { ResultType = "complete" }; } \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Protocol/Result.cs b/src/ModelContextProtocol.Core/Protocol/Result.cs index 15eb6fa46..f88638f6f 100644 --- a/src/ModelContextProtocol.Core/Protocol/Result.cs +++ b/src/ModelContextProtocol.Core/Protocol/Result.cs @@ -27,7 +27,7 @@ private protected Result() /// /// /// - /// When absent or set to "complete", the result is a normal completed response. + /// When set to "complete", the result is a normal completed response. /// When set to "input_required", the result is an indicating /// that additional input is needed before the request can be completed. /// When set to "task", the result is a indicating that the server diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 935088fc0..ef3486f3a 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -621,6 +621,7 @@ private void ConfigureInitialize(McpServerOptions options) Instructions = options.ServerInstructions, ServerInfo = options.ServerInfo ?? DefaultImplementation, Capabilities = ServerCapabilities ?? new(), + ResultType = "complete", }; }, McpJsonUtilities.JsonContext.Default.InitializeRequestParams, @@ -651,6 +652,7 @@ private void ConfigureDiscover(McpServerOptions options) // their "do not cache" behavior while satisfying the wire requirement. TimeToLive = TimeSpan.Zero, CacheScope = CacheScope.Private, + ResultType = "complete", }); }, McpJsonUtilities.JsonContext.Default.DiscoverRequestParams, @@ -703,7 +705,7 @@ private void ConfigureSubscriptions(McpServerOptions options) await SendSubscriptionAckAsync(statelessSubscription, cancellationToken).ConfigureAwait(false); - return new EmptyResult(); + return EmptyResult.Instance; } // Filter the requested notifications against what the server actually supports. @@ -743,7 +745,7 @@ private void ConfigureSubscriptions(McpServerOptions options) _activeSubscriptions.TryRemove(jsonRpcRequest.Id, out _); } - return new EmptyResult(); + return EmptyResult.Instance; }, McpJsonUtilities.JsonContext.Default.SubscriptionsListenRequestParams, McpJsonUtilities.JsonContext.Default.EmptyResult); @@ -1916,6 +1918,21 @@ private void SetHandler( }; } + if (typeof(Result).IsAssignableFrom(typeof(TResult))) + { + var innerHandler = handler; + handler = async (request, cancellationToken) => + { + var result = await innerHandler(request, cancellationToken).ConfigureAwait(false); + if (result is Result protocolResult && protocolResult.ResultType is null) + { + protocolResult.ResultType = "complete"; + } + + return result; + }; + } + _requestHandlers.Set(method, (request, jsonRpcRequest, cancellationToken) => InvokeHandlerAsync(handler, request, jsonRpcRequest, cancellationToken), @@ -1930,6 +1947,25 @@ private void SetTaskAugmentedHandler( JsonTypeInfo taskResultTypeInfo) where TResult : Result { + var innerHandler = handler; + handler = async (request, cancellationToken) => + { + var result = await innerHandler(request, cancellationToken).ConfigureAwait(false); + if (result.IsTask) + { + if (result.TaskCreated is { ResultType: null } taskCreated) + { + taskCreated.ResultType = "task"; + } + } + else if (result.Result is { ResultType: null } immediateResult) + { + immediateResult.ResultType = "complete"; + } + + return result; + }; + _requestHandlers.SetTaskAugmented(method, (request, jsonRpcRequest, cancellationToken) => InvokeHandlerAsync(handler, request, jsonRpcRequest, cancellationToken), diff --git a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs index 45d72f15b..e5ae0474d 100644 --- a/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/July2026ProtocolConnectionTests.cs @@ -83,6 +83,7 @@ public async Task ServerDiscover_IncludesJuly2026ProtocolVersion() var discoverResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(discoverResult); + Assert.Equal("complete", discoverResult.ResultType); Assert.Equal([McpProtocolVersions.July2026ProtocolVersion], discoverResult.SupportedVersions); } } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs index 9708722f3..e019b7b1d 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs @@ -58,6 +58,21 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer }; } + if (toolName == "async-tool-default-resulttype") + { + // Intentionally leaves ResultType unset so the server boundary is responsible for + // filling in "task" on the wire. + var taskId = store.CreateTask(); + return new CreateTaskResult + { + TaskId = taskId, + Status = McpTaskStatus.Working, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + PollIntervalMs = 50, + }; + } + if (toolName == "input-required-tool") { var taskId = store.CreateTask(McpTaskStatus.InputRequired); @@ -292,6 +307,19 @@ public async Task CreateTaskResult_HasResultTypeTask() Assert.Equal("task", augmented.TaskCreated!.ResultType); } + [Fact] + public async Task CreateTaskResult_WithoutExplicitResultType_ServerFillsTask() + { + await using var client = await CreateMcpClientForServer(); + + var augmented = await client.CallToolRawAsync( + new CallToolRequestParams { Name = "async-tool-default-resulttype" }, + TestContext.Current.CancellationToken); + + Assert.True(augmented.IsTask); + Assert.Equal("task", augmented.TaskCreated!.ResultType); + } + [Fact] public async Task GetTaskAsync_ImmediatelyAfterCreate_Resolves() { diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index c872f9644..c0f85a94a 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -283,6 +283,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Equal("complete", result.ResultType); Assert.Equal(expectedAssemblyName.Name, result.ServerInfo.Name); Assert.Equal(expectedAssemblyName.Version?.ToString() ?? "1.0.0", result.ServerInfo.Version); Assert.Equal("2024-11-05", result.ProtocolVersion); @@ -385,6 +386,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Equal("complete", result.ResultType); Assert.NotNull(result.Capabilities.Extensions); Assert.True(result.Capabilities.Extensions.ContainsKey("io.myext")); }); @@ -404,6 +406,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Equal("complete", result.ResultType); Assert.NotNull(result.Capabilities.Experimental); Assert.True(result.Capabilities.Experimental.ContainsKey("customFeature")); }); @@ -435,6 +438,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Equal("complete", result.ResultType); // Use reflection to verify every public property on ServerCapabilities is non-null. // This catches cases where new capability properties are added but not copied @@ -481,6 +485,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); + Assert.Equal("complete", result.ResultType); Assert.Equal(["test"], result.Completion.Values); Assert.Equal(2, result.Completion.Total); Assert.True(result.Completion.HasMore); @@ -524,6 +529,7 @@ await transport.SendMessageAsync(new JsonRpcRequest Assert.NotNull(response); var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); + Assert.Equal("complete", result.ResultType); Assert.Equal(["cat"], result.Completion.Values); Assert.Equal(1, result.Completion.Total); @@ -567,6 +573,7 @@ await transport.SendMessageAsync(new JsonRpcRequest Assert.NotNull(response); var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); + Assert.Equal("complete", result.ResultType); Assert.Empty(result.Completion.Values); await transport.DisposeAsync(); @@ -616,6 +623,7 @@ await transport.SendMessageAsync(new JsonRpcRequest Assert.NotNull(response); var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); + Assert.Equal("complete", result.ResultType); Assert.Equal(["us-east-1", "us-west-2"], result.Completion.Values); Assert.Equal(2, result.Completion.Total); @@ -671,6 +679,7 @@ await transport.SendMessageAsync(new JsonRpcRequest Assert.NotNull(response); var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); + Assert.Equal("complete", result.ResultType); // Custom handler values + auto-populated values should be combined Assert.Equal(["custom-value", "dog", "cat"], result.Completion.Values); Assert.Equal(3, result.Completion.Total); @@ -718,6 +727,7 @@ await transport.SendMessageAsync(new JsonRpcRequest Assert.NotNull(response); var result = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Completion); + Assert.Equal("complete", result.ResultType); Assert.Equal(["a", "b"], result.Completion.Values); await transport.DisposeAsync(); @@ -756,6 +766,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.ResourceTemplates); + Assert.Equal("complete", result.ResultType); Assert.NotEmpty(result.ResourceTemplates); Assert.Equal("test", result.ResourceTemplates[0].UriTemplate); }); @@ -785,6 +796,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Resources); + Assert.Equal("complete", result.ResultType); Assert.NotEmpty(result.Resources); Assert.Equal("test", result.Resources[0].Uri); }); @@ -820,6 +832,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Contents); + Assert.Equal("complete", result.ResultType); Assert.NotEmpty(result.Contents); TextResourceContents textResource = Assert.IsType(result.Contents[0]); @@ -857,6 +870,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result?.Prompts); + Assert.Equal("complete", result.ResultType); Assert.NotEmpty(result.Prompts); Assert.Equal("test", result.Prompts[0].Name); }); @@ -886,6 +900,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Equal("complete", result.ResultType); Assert.Equal("test", result.Description); }); } @@ -920,6 +935,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Equal("complete", result.ResultType); Assert.NotEmpty(result.Tools); Assert.Equal("test", result.Tools[0].Name); }); @@ -955,6 +971,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Equal("complete", result.ResultType); Assert.NotEmpty(result.Content); Assert.Equal("test", Assert.IsType(result.Content[0]).Text); }); @@ -966,6 +983,34 @@ public async Task Can_Handle_Call_Tool_Requests_Throws_Exception_If_No_Handler_A await Succeeds_Even_If_No_Handler_Assigned(new ServerCapabilities { Tools = new() }, RequestMethods.ToolsCall, "CallTool handler not configured"); } + [Fact] + public async Task Can_Handle_SetLoggingLevel_Requests() + { + await Can_Handle_Requests( + new ServerCapabilities + { + Logging = new() + }, + method: RequestMethods.LoggingSetLevel, + configureOptions: options => + { + // logging/setLevel is a legacy (2025-06-18) method whose result must serialize as an + // empty object {}. The custom handler returns a bare result and the server must not + // add a resultType, otherwise the MCP conformance suite rejects the response. + options.Handlers.SetLoggingLevelHandler = async (request, ct) => new EmptyResult(); + }, + assertResult: (_, response) => + { + var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); + Assert.NotNull(result); + Assert.Null(result.ResultType); + + // The wire response must be exactly {} with no additional properties. + var obj = Assert.IsType(response); + Assert.Empty(obj); + }); + } + [Fact] public async Task Can_Handle_Call_Tool_Requests_With_McpException() { @@ -988,6 +1033,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Equal("complete", result.ResultType); Assert.True(result.IsError); Assert.NotEmpty(result.Content); var textContent = Assert.IsType(result.Content[0]); @@ -1016,6 +1062,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Equal("complete", result.ResultType); Assert.True(result.IsError); Assert.NotEmpty(result.Content); var textContent = Assert.IsType(result.Content[0]); @@ -1051,6 +1098,7 @@ await Can_Handle_Requests( { var result = JsonSerializer.Deserialize(response, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); + Assert.Equal("complete", result.ResultType); Assert.True(result.IsError, "Input validation errors should be returned as tool execution errors (IsError=true), not protocol errors"); Assert.NotEmpty(result.Content); var textContent = Assert.IsType(result.Content[0]); @@ -1311,6 +1359,7 @@ await transport.SendClientMessageAsync(new JsonRpcNotification Assert.NotNull(response.Result); var initResult = JsonSerializer.Deserialize(response.Result, McpJsonUtilities.DefaultOptions); Assert.NotNull(initResult); + Assert.Equal("complete", initResult.ResultType); Assert.NotNull(initResult.ServerInfo); await transport.DisposeAsync(); From 72c77c1db16af26d0f24eebc7f57c8f571528cb8 Mon Sep 17 00:00:00 2001 From: Jeff Handley Date: Tue, 14 Jul 2026 11:09:03 -0700 Subject: [PATCH 16/16] Address review feedback on Tasks extension seams - Mark ResultOrAlternate, CallToolWithAlternateHandler, and CallToolWithAlternateFilters as [Experimental(MCPEXP002)] - Set ResultType = "task" in the CreateTaskResult constructor and drop the redundant assignment in ToCreateTaskResult - Replace the public untyped ResultOrAlternate constructor with a typed FromAlternate(TAlternate, JsonTypeInfo) factory and make the untyped constructor private - Normalize a returned InputRequiredResult through the alternate path with a thrown InputRequiredException in the MRTR backcompat resolver - Add MRTR tests for the returned-result form (server-side backcompat resolution and native MRTR round-trip) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Protocol/ResultOrAlternate.cs | 19 ++- .../RequestHandlers.cs | 2 + .../Server/McpRequestFilters.cs | 4 + .../Server/McpServerHandlers.cs | 4 + .../Server/McpServerImpl.cs | 155 +++++++++++------- .../Protocol/CreateTaskResult.cs | 8 + .../Server/McpTasksBuilderExtensions.cs | 3 +- .../Server/CallToolFilterMixingTests.cs | 2 + .../Server/McpServerTaskTests.cs | 4 +- .../Server/MrtrInputRequiredExceptionTests.cs | 87 ++++++++++ .../Server/MrtrServerBackcompatTests.cs | 88 ++++++++++ ...TaskHandlerConfigurationValidationTests.cs | 6 +- .../Server/TaskPollStuckDetectorTests.cs | 2 +- .../Server/TaskStoreOrphanedTaskTests.cs | 8 +- 14 files changed, 322 insertions(+), 70 deletions(-) diff --git a/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs b/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs index e2e1f112e..0944ba135 100644 --- a/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs +++ b/src/ModelContextProtocol.Core/Protocol/ResultOrAlternate.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.Protocol; @@ -19,6 +20,7 @@ namespace ModelContextProtocol.Protocol; /// for the immediate result or for the alternate. /// /// +[Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] public class ResultOrAlternate where TResult : Result { private readonly TResult? _result; @@ -40,7 +42,7 @@ public ResultOrAlternate(TResult result) /// /// The alternate result. /// The used to serialize the alternate result. - public ResultOrAlternate(Result alternate, JsonTypeInfo alternateTypeInfo) + private ResultOrAlternate(Result alternate, JsonTypeInfo alternateTypeInfo) { Throw.IfNull(alternate); Throw.IfNull(alternateTypeInfo); @@ -48,6 +50,21 @@ public ResultOrAlternate(Result alternate, JsonTypeInfo alternateTypeInfo) _alternateTypeInfo = alternateTypeInfo; } + /// + /// Creates a that carries an alternate subtype + /// (for example an InputRequiredResult or a task-creation result) in place of the standard result. + /// + /// The concrete alternate result type. + /// The alternate result to return instead of the standard result. + /// + /// The used to serialize . Requiring the strongly-typed + /// contract keeps the alternate value paired with matching serializer metadata rather than an unrelated type. + /// + /// A that wraps the alternate result. + public static ResultOrAlternate FromAlternate(TAlternate alternate, JsonTypeInfo alternateTypeInfo) + where TAlternate : Result + => new(alternate, alternateTypeInfo); + /// /// Gets a value indicating whether the server returned an alternate result instead of the standard result. /// diff --git a/src/ModelContextProtocol.Core/RequestHandlers.cs b/src/ModelContextProtocol.Core/RequestHandlers.cs index ff167cc7d..2a21008f4 100644 --- a/src/ModelContextProtocol.Core/RequestHandlers.cs +++ b/src/ModelContextProtocol.Core/RequestHandlers.cs @@ -46,6 +46,7 @@ public void Set( }; } +#pragma warning disable MCPEXP002 // SetWithAlternate consumes the experimental ResultOrAlternate seam /// /// Registers a handler that may return either a standard result or an alternate /// subtype for scenarios like task-augmented execution. @@ -75,4 +76,5 @@ public void SetWithAlternate( return JsonSerializer.SerializeToNode(augmented.Result!, responseTypeInfo); }; } +#pragma warning restore MCPEXP002 } diff --git a/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs b/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs index a82baf598..cb8c9db23 100644 --- a/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs +++ b/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs @@ -1,4 +1,5 @@ using ModelContextProtocol.Protocol; +using System.Diagnostics.CodeAnalysis; namespace ModelContextProtocol.Server; @@ -58,6 +59,7 @@ public IList> CallToolFi } } +#pragma warning disable MCPEXP002 // CallToolWithAlternateFilters references the experimental ResultOrAlternate seam /// /// Gets or sets the filters for the call-tool handler pipeline with alternate result support. /// @@ -75,6 +77,7 @@ public IList> CallToolFi /// authorization filters (which use ). /// /// + [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] public IList>> CallToolWithAlternateFilters { get => field ??= []; @@ -84,6 +87,7 @@ public IList /// Gets or sets the filters for the list-prompts handler pipeline. diff --git a/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs b/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs index 37d2a3d73..7e655603a 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerHandlers.cs @@ -1,4 +1,5 @@ using ModelContextProtocol.Protocol; +using System.Diagnostics.CodeAnalysis; namespace ModelContextProtocol.Server; @@ -36,6 +37,7 @@ public sealed class McpServerHandlers /// public McpRequestHandler? ListToolsHandler { get; set; } +#pragma warning disable MCPEXP002 // CallToolHandler and CallToolWithAlternateHandler reference the experimental ResultOrAlternate seam /// /// Gets or sets the handler for requests. /// @@ -74,6 +76,7 @@ public McpRequestHandler? CallToolHandler /// /// /// is already set. + [Experimental(Experimentals.Subclassing_DiagnosticId, UrlFormat = Experimentals.Subclassing_Url)] public McpRequestHandler>? CallToolWithAlternateHandler { get; @@ -88,6 +91,7 @@ public McpRequestHandler /// Gets or sets the handler for requests. diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index da7417a2a..8b38421c4 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -1298,6 +1298,7 @@ await originalListPromptsHandler(request, cancellationToken).ConfigureAwait(fals McpJsonUtilities.JsonContext.Default.GetPromptResult); } +#pragma warning disable MCPEXP002 // tool dispatch wires up the experimental alternate call-tool handler and filters private void ConfigureTools(McpServerOptions options) { var listToolsHandler = options.Handlers.ListToolsHandler; @@ -1536,6 +1537,7 @@ private McpRequestFilter( requestTypeInfo, responseTypeInfo); } +#pragma warning disable MCPEXP002 // SetWithAlternateHandler wraps the experimental ResultOrAlternate seam private void SetWithAlternateHandler( string method, McpRequestHandler> handler, @@ -1705,6 +1708,7 @@ private void SetWithAlternateHandler( InvokeHandlerAsync(handler, request, jsonRpcRequest, cancellationToken), requestTypeInfo, responseTypeInfo); } +#pragma warning restore MCPEXP002 private static McpRequestHandler BuildFilterPipeline( McpRequestHandler baseHandler, @@ -1832,75 +1836,92 @@ internal bool IsJuly2026OrLaterProtocolRequest(JsonRpcMessageContext? requestCon for (int retry = 0; ; retry++) { + InputRequiredResult inputRequiredResult; + Exception? inputRequiredException = null; + try { - return await handler(request, cancellationToken).ConfigureAwait(false); + var result = await handler(request, cancellationToken).ConfigureAwait(false); + + // A handler can surface an input-required result two ways: by throwing InputRequiredException, + // or by RETURNING an InputRequiredResult through the alternate result path (ResultOrAlternate). + // Normalize both forms so a client that doesn't natively support MRTR gets the same server-side + // resolution either way. + if (GetReturnedInputRequiredResult(result) is not { } returnedInputRequired) + { + return result; + } + + inputRequiredResult = returnedInputRequired; } catch (InputRequiredException ex) { - // If the client natively supports MRTR, serialize and return directly - - // the client will drive the retry loop. - if (ClientSupportsMrtr()) - { - return SerializeInputRequiredResult(ex.Result); - } + inputRequiredResult = ex.Result; + inputRequiredException = ex; + } - // In stateless mode without MRTR, the server can't resolve input requests via - // JSON-RPC (no persistent session for server-to-client requests), and the client - // won't recognize the InputRequiredResult. This is the one unsupported configuration. - if (!HasStatefulTransport()) - { - throw new McpException( - "A tool handler returned an incomplete result, but the server is stateless and the client does not support MRTR. " + - "MRTR-native tools require either an MRTR-capable client or a stateful server for backward-compatible resolution.", ex); - } + // If the client natively supports MRTR, serialize and return directly - + // the client will drive the retry loop. + if (ClientSupportsMrtr()) + { + return SerializeInputRequiredResult(inputRequiredResult); + } - // 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); - } + // In stateless mode without MRTR, the server can't resolve input requests via + // JSON-RPC (no persistent session for server-to-client requests), and the client + // won't recognize the InputRequiredResult. This is the one unsupported configuration. + if (!HasStatefulTransport()) + { + throw new McpException( + "A tool handler returned an incomplete result, but the server is stateless and the client does not support MRTR. " + + "MRTR-native tools require either an MRTR-capable client or a stateful server for backward-compatible resolution.", inputRequiredException); + } - if (retry >= MaxRetries) - { - throw new McpException( - $"MRTR-native tool exceeded {MaxRetries} retry rounds without completing.", ex); - } + // Backcompat: resolve input requests via standard JSON-RPC calls and retry the handler. + if (inputRequiredResult.InputRequests is not { Count: > 0 } inputRequests) + { + throw new McpException( + "A tool handler returned an incomplete result without input requests, and the client does not support MRTR.", inputRequiredException); + } - // 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"); - } + if (retry >= MaxRetries) + { + throw new McpException( + $"MRTR-native tool exceeded {MaxRetries} retry rounds without completing.", inputRequiredException); + } - request = new JsonRpcRequest - { - Id = request.Id, - Method = request.Method, - Params = paramsObj, - Context = request.Context, - }; + // 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 (inputRequiredResult.RequestState is { } requestState) + { + paramsObj["requestState"] = requestState; + } + else + { + // Strip any stale requestState carried over from the previous round's clone so + // the next tool invocation doesn't see a continuation token the current round is not using. + paramsObj.Remove("requestState"); } + + request = new JsonRpcRequest + { + Id = request.Id, + Method = request.Method, + Params = paramsObj, + Context = request.Context, + }; } } @@ -1988,6 +2009,24 @@ private static async Task ResolveInputRequestAsync(McpServer dest private static JsonNode? SerializeInputRequiredResult(InputRequiredResult inputRequiredResult) => JsonSerializer.SerializeToNode(inputRequiredResult, McpJsonUtilities.JsonContext.Default.InputRequiredResult); + /// + /// Detects an that a handler surfaced by RETURNING it through the alternate + /// result path (rather than throwing ), so both forms can be resolved + /// identically for clients that don't natively support MRTR. Returns for any other result. + /// + private static InputRequiredResult? GetReturnedInputRequiredResult(JsonNode? result) + { + if (result is JsonObject resultObject && + resultObject.TryGetPropertyValue("resultType", out var resultTypeNode) && + resultTypeNode?.GetValueKind() == JsonValueKind.String && + resultTypeNode.GetValue() == "input_required") + { + return JsonSerializer.Deserialize(result, McpJsonUtilities.JsonContext.Default.InputRequiredResult); + } + + return null; + } + /// /// 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. diff --git a/src/ModelContextProtocol.Extensions.Tasks/Protocol/CreateTaskResult.cs b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CreateTaskResult.cs index 9e262e5c0..7cbb5d134 100644 --- a/src/ModelContextProtocol.Extensions.Tasks/Protocol/CreateTaskResult.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Protocol/CreateTaskResult.cs @@ -25,6 +25,14 @@ namespace ModelContextProtocol.Extensions.Tasks; /// public sealed class CreateTaskResult : Result { + /// + /// Initializes a new instance of the class. + /// + public CreateTaskResult() + { + ResultType = "task"; + } + /// /// Gets or sets the stable identifier for this task. /// diff --git a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs index b119eefa8..06073ce17 100644 --- a/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Tasks/Server/McpTasksBuilderExtensions.cs @@ -177,7 +177,7 @@ public void PostConfigure(string? name, McpServerOptions options) } }, CancellationToken.None); - return new ResultOrAlternate( + return ResultOrAlternate.FromAlternate( ToCreateTaskResult(taskInfo), McpTasksJsonContext.Default.CreateTaskResult); } @@ -269,7 +269,6 @@ private static bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest? request) => TimeToLive = info.TimeToLive, PollIntervalMs = info.PollIntervalMs, StatusMessage = info.StatusMessage, - ResultType = "task", }; private static GetTaskResult ToGetTaskResult(McpTaskInfo info) => info.Status switch diff --git a/tests/ModelContextProtocol.Tests/Server/CallToolFilterMixingTests.cs b/tests/ModelContextProtocol.Tests/Server/CallToolFilterMixingTests.cs index 259896bcb..3c9b8bb9d 100644 --- a/tests/ModelContextProtocol.Tests/Server/CallToolFilterMixingTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/CallToolFilterMixingTests.cs @@ -11,6 +11,7 @@ namespace ModelContextProtocol.Tests.Server; /// public class CallToolFilterMixingTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper) { +#pragma warning disable MCPEXP002 // exercises the experimental CallToolWithAlternateFilters seam private static McpRequestFilter PassThroughCallToolFilter => next => next; @@ -55,4 +56,5 @@ public async Task AlternateFiltersAlone_Succeeds() await using var server = McpServer.Create(transport, options, LoggerFactory); Assert.NotNull(server); } +#pragma warning restore MCPEXP002 } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs index 94bb4dcd0..669499c12 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs @@ -48,7 +48,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer { Content = [new TextContentBlock { Text = "immediate result" }], }), - "async-tool" => new( + "async-tool" => ResultOrAlternate.FromAlternate( new CreateTaskResult { TaskId = store.CreateTask(), @@ -59,7 +59,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer ResultType = "task", }, McpTasksJsonContext.Default.CreateTaskResult), - "input-required-tool" => new( + "input-required-tool" => ResultOrAlternate.FromAlternate( new CreateTaskResult { TaskId = store.CreateTask(McpTaskStatus.InputRequired), diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs index a263e289b..3d927b163 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrInputRequiredExceptionTests.cs @@ -1,8 +1,10 @@ using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; +using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.Tests.Server; @@ -59,3 +61,88 @@ public async Task InputRequiredException_WithoutInputRequests_ExhaustsRetries() Assert.Contains("more than", exception.Message); } } + +/// +/// Companion to covering a native (MRTR-capable) round-trip where the +/// server RETURNS an through the alternate result path +/// () rather than throwing . The MRTR +/// client drives the round-trip and receives the final result. +/// +public class MrtrReturnedInputRequiredResultNativeTests : ClientServerTestBase +{ + private static readonly JsonTypeInfo s_inputRequiredResultTypeInfo = + (JsonTypeInfo)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(InputRequiredResult)); + + private int _attempt; + + public MrtrReturnedInputRequiredResultNativeTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + +#pragma warning disable MCPEXP002 // exercises the experimental CallToolWithAlternateHandler/ResultOrAlternate seam + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + services.Configure(options => + { + options.ProtocolVersion = "2026-07-28"; + + options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => + { + Interlocked.Increment(ref _attempt); + + // Retry round: the MRTR client re-sent the request with its responses and our requestState. + if (context.Params?.RequestState is not null) + { + return new ValueTask>(new CallToolResult + { + Content = [new TextContentBlock { Text = "resolved" }], + }); + } + + // First round: RETURN an InputRequiredResult through the alternate path. An MRTR client + // understands it natively and drives the round-trip. + var inputRequired = new InputRequiredResult + { + InputRequests = new Dictionary + { + ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "need-input", + RequestedSchema = new(), + }), + }, + RequestState = "round1", + }; + + return new ValueTask>( + ResultOrAlternate.FromAlternate(inputRequired, s_inputRequiredResultTypeInfo)); + }; + }); + } +#pragma warning restore MCPEXP002 + + [Fact] + public async Task ReturnedInputRequiredResult_MrtrClient_RoundTripsToFinalResult() + { + StartServer(); + + var clientOptions = new McpClientOptions + { + Capabilities = new ClientCapabilities { Elicitation = new() }, + }; + clientOptions.Handlers.ElicitationHandler = (_, _) => + new ValueTask(new ElicitResult { Action = "accept" }); + + await using var client = await CreateMcpClientForServer(clientOptions); + + var result = await client.CallToolAsync( + "return-form", + cancellationToken: TestContext.Current.CancellationToken); + + // Two handler invocations: initial (returned InputRequiredResult) + client-driven retry (final result). + Assert.Equal(2, _attempt); + var content = Assert.Single(result.Content); + Assert.Equal("resolved", Assert.IsType(content).Text); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs b/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs index 221a2ffb4..3c5a52d92 100644 --- a/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/MrtrServerBackcompatTests.cs @@ -1,5 +1,7 @@ using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -111,3 +113,89 @@ public async Task InputRequiredException_TransitioningRequestStateToNull_DoesNot Assert.Equal("final-state:", text); } } + +/// +/// Companion to covering the other way a handler can surface an +/// input-required result to a non-MRTR client: by RETURNING an through the +/// alternate result path () instead of throwing +/// . The legacy backcompat resolver must normalize both forms so a non-MRTR +/// stateful client gets the same server-side resolution either way. +/// +public class MrtrReturnedInputRequiredResultBackcompatTests : ClientServerTestBase +{ + private static readonly JsonTypeInfo s_inputRequiredResultTypeInfo = + (JsonTypeInfo)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(InputRequiredResult)); + + private int _attempt; + + public MrtrReturnedInputRequiredResultBackcompatTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + +#pragma warning disable MCPEXP002 // exercises the experimental CallToolWithAlternateHandler/ResultOrAlternate seam + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.Services.Configure(options => + { + options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => + { + Interlocked.Increment(ref _attempt); + + // Retry round: the backcompat resolver re-invoked us with the client's responses. + if (context.Params?.RequestState is not null) + { + return new ValueTask>(new CallToolResult + { + Content = [new TextContentBlock { Text = "resolved" }], + }); + } + + // First round: RETURN an InputRequiredResult through the alternate path rather than throwing. + var inputRequired = new InputRequiredResult + { + InputRequests = new Dictionary + { + ["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = "need-input", + RequestedSchema = new(), + }), + }, + RequestState = "round1", + }; + + return new ValueTask>( + ResultOrAlternate.FromAlternate(inputRequired, s_inputRequiredResultTypeInfo)); + }; + }); + } +#pragma warning restore MCPEXP002 + + [Fact] + public async Task ReturnedInputRequiredResult_NonMrtrStatefulClient_ResolvedServerSide() + { + StartServer(); + + // Non-MRTR client → server falls into the legacy backcompat resolver path, which must handle a + // RETURNED InputRequiredResult exactly like a thrown InputRequiredException. + var clientOptions = new McpClientOptions + { + ProtocolVersion = "2025-06-18", + Capabilities = new ClientCapabilities { Elicitation = new() }, + }; + clientOptions.Handlers.ElicitationHandler = (_, _) => + new ValueTask(new ElicitResult { Action = "accept" }); + + await using var client = await CreateMcpClientForServer(clientOptions); + + var result = await client.CallToolAsync( + "return-form", + cancellationToken: TestContext.Current.CancellationToken); + + // Two handler invocations: initial (returned InputRequiredResult) + retry (final result). + Assert.Equal(2, _attempt); + var content = Assert.Single(result.Content); + Assert.Equal("resolved", Assert.IsType(content).Text); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs index 84f32267a..621acfddb 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskHandlerConfigurationValidationTests.cs @@ -16,7 +16,7 @@ namespace ModelContextProtocol.Tests.Server; /// public class TaskHandlerConfigurationValidationTests : ClientServerTestBase { - private static readonly JsonTypeInfo s_createTaskResultTypeInfo = McpTasksJsonContext.Default.CreateTaskResult; + private static readonly JsonTypeInfo s_createTaskResultTypeInfo = McpTasksJsonContext.Default.CreateTaskResult; public TaskHandlerConfigurationValidationTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { @@ -25,6 +25,7 @@ public TaskHandlerConfigurationValidationTests(ITestOutputHelper testOutputHelpe #endif } +#pragma warning disable MCPEXP002 // exercises the experimental CallToolWithAlternateHandler/ResultOrAlternate seam protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { mcpServerBuilder.Services.Configure(options => @@ -35,7 +36,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer // task lifecycle request handlers. options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => new ValueTask>( - new ResultOrAlternate( + ResultOrAlternate.FromAlternate( new CreateTaskResult { TaskId = "orphan-task", @@ -46,6 +47,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer s_createTaskResultTypeInfo)); }); } +#pragma warning restore MCPEXP002 [Fact] public async Task ServerAcceptsAlternateHandler_WithoutTasksGetHandler_NoStartupError() diff --git a/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs index 58a0e68ec..c2552abd3 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs @@ -42,7 +42,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer { var taskId = Guid.NewGuid().ToString("N"); return new ValueTask>( - new ResultOrAlternate( + ResultOrAlternate.FromAlternate( new CreateTaskResult { TaskId = taskId, diff --git a/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs index 97221b2f9..144b08476 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskStoreOrphanedTaskTests.cs @@ -7,8 +7,6 @@ using System.Text.Json; using System.Text.Json.Serialization.Metadata; -#pragma warning disable MCPEXP001 - namespace ModelContextProtocol.Tests.Server; /// @@ -19,7 +17,8 @@ namespace ModelContextProtocol.Tests.Server; /// public class TaskStoreOrphanedTaskTests : ClientServerTestBase { - private static readonly JsonTypeInfo s_createTaskResultTypeInfo = McpTasksJsonContext.Default.CreateTaskResult; +#pragma warning disable MCPEXP002 // exercises the experimental CallToolWithAlternateHandler/ResultOrAlternate seam + private static readonly JsonTypeInfo s_createTaskResultTypeInfo = McpTasksJsonContext.Default.CreateTaskResult; public TaskStoreOrphanedTaskTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { @@ -40,7 +39,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer // misconfiguration the server must guard against. options.Handlers.CallToolWithAlternateHandler = (context, cancellationToken) => new ValueTask>( - new ResultOrAlternate( + ResultOrAlternate.FromAlternate( new CreateTaskResult { TaskId = "user-task", @@ -87,4 +86,5 @@ public async Task TaskStoreAndHandler_BothCreatingTasks_FailsStoreTaskWithClearE Assert.Contains(nameof(IMcpTaskStore), message); Assert.Contains(nameof(McpServerHandlers.CallToolWithAlternateHandler), message); } +#pragma warning restore MCPEXP002 }