diff --git a/src/ModelContextProtocol.Core/Server/AlternateResultFilterCollection.cs b/src/ModelContextProtocol.Core/Server/AlternateResultFilterCollection.cs new file mode 100644 index 000000000..85268ff80 --- /dev/null +++ b/src/ModelContextProtocol.Core/Server/AlternateResultFilterCollection.cs @@ -0,0 +1,131 @@ +using ModelContextProtocol.Protocol; +using System.Diagnostics.CodeAnalysis; + +namespace ModelContextProtocol.Server; + +#pragma warning disable MCPEXP002 // this collection is itself part of the experimental ResultOrAlternate seam + +/// +/// Stores alternate-result filters keyed by JSON-RPC method name, so that augmenting a Core-dispatched +/// method to return an alternate subtype does not require a new +/// <Method>WithAlternateHandler/<Method>WithAlternateFilters pair per method. +/// +/// +/// +/// Each method may only be registered with a single (TParams, TResult) pair, matching the +/// types Core dispatches that method with. Registering the same method with different types throws, because +/// the mismatched filters could never be invoked. +/// +/// +/// is a typed view over the +/// entry of this collection. +/// +/// +[Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)] +public sealed class AlternateResultFilterCollection +{ + private readonly Dictionary _entries = new(StringComparer.Ordinal); + + /// + /// Gets the filter list registered for , creating an empty one if the method + /// has not been registered yet. + /// + /// The request parameter type Core dispatches with. + /// The result type Core dispatches with. + /// The JSON-RPC method name, for example . + /// The mutable filter list for . + /// + /// was already registered with a different parameter or result type. + /// + public IList>> GetOrAdd(string method) + where TResult : Result + { + Throw.IfNullOrWhiteSpace(method); + + if (_entries.TryGetValue(method, out var entry)) + { + return Cast(entry, method); + } + + var filters = new List>>(); + _entries[method] = new Entry(typeof(TParams), typeof(TResult), filters, () => filters.Count); + return filters; + } + + /// + /// Replaces the filter list registered for . + /// + /// The request parameter type Core dispatches with. + /// The result type Core dispatches with. + /// The JSON-RPC method name. + /// The filter list to store. + public void Set( + string method, + IList>> filters) + where TResult : Result + { + Throw.IfNullOrWhiteSpace(method); + Throw.IfNull(filters); + + _entries[method] = new Entry(typeof(TParams), typeof(TResult), filters, () => filters.Count); + } + + /// + /// Gets the method names that currently have at least one registered filter. + /// + internal IEnumerable NonEmptyMethods + { + get + { + foreach (var entry in _entries) + { + if (entry.Value.Count > 0) + { + yield return entry.Key; + } + } + } + } + + internal bool TryGetNonEmpty( + string method, + [NotNullWhen(true)] out IList>>? filters) + where TResult : Result + { + if (_entries.TryGetValue(method, out var entry) && entry.Count > 0) + { + filters = Cast(entry, method); + return true; + } + + filters = null; + return false; + } + + private static IList>> Cast( + Entry entry, + string method) + where TResult : Result + { + if (entry.Filters is IList>> typed) + { + return typed; + } + + throw new InvalidOperationException( + $"Alternate-result filters for method '{method}' were registered with " + + $"({entry.ParamsType}, {entry.ResultType}), but ({typeof(TParams)}, {typeof(TResult)}) was requested. " + + $"A method can only carry alternate-result filters for the types the server dispatches it with."); + } + + private sealed class Entry(Type paramsType, Type resultType, object filters, Func count) + { + public Type ParamsType { get; } = paramsType; + + public Type ResultType { get; } = resultType; + + public object Filters { get; } = filters; + + public int Count => count(); + } +} diff --git a/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs b/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs index a1aad7112..67f5a34ea 100644 --- a/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs +++ b/src/ModelContextProtocol.Core/Server/McpRequestFilters.cs @@ -66,6 +66,24 @@ public IList> CallToolFi } } + /// + /// Gets the alternate-result filters registered for arbitrary JSON-RPC methods. + /// + /// + /// + /// Alternate-result filters let an extension augment a Core-dispatched method so it can return an alternate + /// subtype (for example a task-creation result) in place of the method's normal result. + /// The collection is keyed by method name, so enabling a new method is an extension-only change rather than a + /// new <Method>WithAlternateHandler/<Method>WithAlternateFilters pair on Core. + /// + /// + /// is a typed view over the + /// entry of this collection, so the two stay in sync. + /// + /// + [Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)] + public AlternateResultFilterCollection AlternateResultFilters => field ??= new(); + /// /// Gets or sets the filters for the call-tool handler pipeline with alternate result support. /// @@ -83,15 +101,19 @@ public IList> CallToolFi /// Alternate-result filters run in registration order. If one filter dispatches the remainder of the pipeline /// asynchronously, filters registered after it execute as part of that asynchronous operation. /// + /// + /// This property is a view over the entry of + /// . Filters added through either API share one ordered list. + /// /// [Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)] public IList>> CallToolWithAlternateFilters { - get => field ??= []; + get => AlternateResultFilters.GetOrAdd(RequestMethods.ToolsCall); set { Throw.IfNull(value); - field = value; + AlternateResultFilters.Set(RequestMethods.ToolsCall, value); } } #pragma warning restore MCPEXP002 diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index f68aacdff..610db20f3 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -49,6 +49,9 @@ internal sealed partial class McpServerImpl : McpServer private ClientCapabilities? _clientCapabilities; private Implementation? _clientInfo; + /// Methods whose alternate-result filters were actually wired into a dispatched handler. + private readonly HashSet _alternateResultMethodsApplied = new(StringComparer.Ordinal); + private readonly string _serverOnlyEndpointName; private string? _negotiatedProtocolVersion; private string _endpointName; @@ -109,6 +112,7 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact ConfigureExperimentalAndExtensions(options); ConfigureMrtr(); ConfigureCustomRequestHandlers(options); + ValidateAlternateResultRegistrations(options); // Register any notification handlers that were provided. if (options.Handlers.NotificationHandlers is { } notificationHandlers) @@ -973,7 +977,7 @@ private void ConfigureCompletion(McpServerOptions options) ServerCapabilities.Completions = new(); - SetHandler( + SetHandlerWithAlternateSupport( RequestMethods.CompletionComplete, completeHandler, McpJsonUtilities.JsonContext.Default.CompleteRequestParams, @@ -1240,31 +1244,31 @@ await originalListResourceTemplatesHandler(request, cancellationToken).Configure ServerCapabilities.Resources.ListChanged = listChanged; ServerCapabilities.Resources.Subscribe = subscribe; - SetHandler( + SetHandlerWithAlternateSupport( RequestMethods.ResourcesList, listResourcesHandler, McpJsonUtilities.JsonContext.Default.ListResourcesRequestParams, McpJsonUtilities.JsonContext.Default.ListResourcesResult); - SetHandler( + SetHandlerWithAlternateSupport( RequestMethods.ResourcesTemplatesList, listResourceTemplatesHandler, McpJsonUtilities.JsonContext.Default.ListResourceTemplatesRequestParams, McpJsonUtilities.JsonContext.Default.ListResourceTemplatesResult); - SetHandler( + SetHandlerWithAlternateSupport( RequestMethods.ResourcesRead, readResourceHandler, McpJsonUtilities.JsonContext.Default.ReadResourceRequestParams, McpJsonUtilities.JsonContext.Default.ReadResourceResult); - SetHandler( + SetHandlerWithAlternateSupport( RequestMethods.ResourcesSubscribe, subscribeHandler, McpJsonUtilities.JsonContext.Default.SubscribeRequestParams, McpJsonUtilities.JsonContext.Default.EmptyResult); - SetHandler( + SetHandlerWithAlternateSupport( RequestMethods.ResourcesUnsubscribe, unsubscribeHandler, McpJsonUtilities.JsonContext.Default.UnsubscribeRequestParams, @@ -1351,13 +1355,13 @@ await originalListPromptsHandler(request, cancellationToken).ConfigureAwait(fals ServerCapabilities.Prompts.ListChanged = listChanged; - SetHandler( + SetHandlerWithAlternateSupport( RequestMethods.PromptsList, listPromptsHandler, McpJsonUtilities.JsonContext.Default.ListPromptsRequestParams, McpJsonUtilities.JsonContext.Default.ListPromptsResult); - SetHandler( + SetHandlerWithAlternateSupport( RequestMethods.PromptsGet, getPromptHandler, McpJsonUtilities.JsonContext.Default.GetPromptRequestParams, @@ -1487,12 +1491,13 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) } ServerCapabilities.Tools.ListChanged = listChanged; - SetHandler( + SetHandlerWithAlternateSupport( RequestMethods.ToolsList, listToolsHandler, McpJsonUtilities.JsonContext.Default.ListToolsRequestParams, McpJsonUtilities.JsonContext.Default.ListToolsResult); + _alternateResultMethodsApplied.Add(RequestMethods.ToolsCall); SetWithAlternateHandler( RequestMethods.ToolsCall, callToolWithAlternateHandler, @@ -1761,30 +1766,7 @@ private void SetHandler( JsonTypeInfo requestTypeInfo, JsonTypeInfo responseTypeInfo) { - // SEP-2549: results that carry caching hints (tools/list, prompts/list, resources/list, - // resources/templates/list, and resources/read) declare ttlMs and cacheScope as required fields. - // When a handler leaves them unset, fill in conservative defaults (immediately stale and not - // shareable) so the wire form always carries the fields while preserving today's "don't cache" - // behavior. Any value supplied by the handler or a filter is left untouched. - if (typeof(ICacheableResult).IsAssignableFrom(typeof(TResult))) - { - var innerHandler = handler; - handler = async (request, cancellationToken) => - { - var result = await innerHandler(request, cancellationToken).ConfigureAwait(false); - - // ttlMs and cacheScope are 2026-07-28 result fields; only stamp them when the request - // was negotiated under that revision or later. Earlier revisions (e.g. 2025-11-25) reject - // these as unrecognized keys (issue #1721). - if (result is ICacheableResult cacheable && IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest)) - { - cacheable.TimeToLive ??= TimeSpan.Zero; - cacheable.CacheScope ??= CacheScope.Private; - } - - return result; - }; - } + handler = WrapWithCacheableDefaults(handler); if (typeof(Result).IsAssignableFrom(typeof(TResult))) { @@ -1812,6 +1794,94 @@ private void SetHandler( requestTypeInfo, responseTypeInfo); } + /// + /// Registers a built-in typed handler, routing it through the method-keyed alternate-result pipeline + /// when registered filters for + /// . + /// + /// + /// Only adaptation and filter composition are generalized here. Per-method policy such as primitive + /// matching and lifecycle logging stays with the method that needs it; tools/call keeps its own + /// composition in . + /// +#pragma warning disable MCPEXP002 // consumes the experimental alternate-result seam + private void SetHandlerWithAlternateSupport( + string method, + McpRequestHandler handler, + JsonTypeInfo requestTypeInfo, + JsonTypeInfo responseTypeInfo) + where TResult : Result + { + if (!ServerOptions.Filters.Request.AlternateResultFilters.TryGetNonEmpty(method, out var filters)) + { + SetHandler(method, handler, requestTypeInfo, responseTypeInfo); + return; + } + + _alternateResultMethodsApplied.Add(method); + + var ordinaryHandler = WrapWithCacheableDefaults(handler); + McpRequestHandler> adapted = async (request, cancellationToken) => + await ordinaryHandler(request, cancellationToken).ConfigureAwait(false); + + SetWithAlternateHandler( + method, + BuildInvocationFilterPipeline(adapted, filters), + requestTypeInfo, + responseTypeInfo); + } + + /// + /// Throws when alternate-result filters were registered for a method the server never dispatches, so a + /// typo or a missing capability surfaces at construction instead of silently dropping the filters. + /// + private void ValidateAlternateResultRegistrations(McpServerOptions options) + { + foreach (var method in options.Filters.Request.AlternateResultFilters.NonEmptyMethods) + { + if (!_alternateResultMethodsApplied.Contains(method)) + { + throw new InvalidOperationException( + $"Alternate-result filters were registered for method '{method}', but this server does not " + + $"dispatch that method. Register them for a method the server handles, or configure the " + + $"capability that provides it."); + } + } + } +#pragma warning restore MCPEXP002 + + /// + /// SEP-2549: results that carry caching hints (tools/list, prompts/list, resources/list, + /// resources/templates/list, and resources/read) declare ttlMs and cacheScope as required fields. + /// When a handler leaves them unset, fill in conservative defaults (immediately stale and not + /// shareable) so the wire form always carries the fields while preserving today's "don't cache" + /// behavior. Any value supplied by the handler or a filter is left untouched. + /// + private McpRequestHandler WrapWithCacheableDefaults( + McpRequestHandler handler) + { + if (!typeof(ICacheableResult).IsAssignableFrom(typeof(TResult))) + { + return handler; + } + + return async (request, cancellationToken) => + { + var result = await handler(request, cancellationToken).ConfigureAwait(false); + + // ttlMs and cacheScope are 2026-07-28 result fields; only stamp them when the request + // was negotiated under that revision or later. Earlier revisions (e.g. 2025-11-25) reject + // these as unrecognized keys (issue #1721). + if (result is ICacheableResult cacheable && IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest)) + { + cacheable.TimeToLive ??= TimeSpan.Zero; + cacheable.CacheScope ??= CacheScope.Private; + } + + return result; + }; + } + #pragma warning disable MCPEXP002 // SetWithAlternateHandler wraps the experimental ResultOrAlternate seam private void SetWithAlternateHandler( string method, diff --git a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs index 2a26868a1..de5f2b7d1 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs @@ -220,4 +220,40 @@ public McpServerFilters Filters /// [Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)] public IList? RequestHandlers { get; set; } + + /// + /// Registers a filter that can replace the result of a Core-dispatched method with an alternate + /// subtype. + /// + /// The request parameter type the server dispatches with. + /// The result type the server dispatches with. + /// The JSON-RPC method to augment, for example . + /// The filter to append to the method's alternate-result pipeline. + /// + /// + /// Unlike , this seam is method-agnostic: + /// enabling alternate results for another method is a call to this method rather than a new pair of + /// Core APIs. Filters run outside the method's ordinary filter pipeline, in registration order, and + /// receive a handler that produces the method's normal result. + /// + /// + /// Registering a filter for a method the server does not dispatch (for example because the corresponding + /// capability is not configured) throws when the server is constructed, so a typo or a missing capability + /// fails loudly instead of silently dropping the filter. + /// + /// + /// + /// already carries alternate-result filters registered with different types. + /// + [Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)] + public void AddAlternateResultFilter( + string method, + McpRequestInvocationFilter> filter) + where TResult : Result + { + Throw.IfNullOrWhiteSpace(method); + Throw.IfNull(filter); + + Filters.Request.AlternateResultFilters.GetOrAdd(method).Add(filter); + } } diff --git a/tests/ModelContextProtocol.Tests/Server/AlternateResultFilterTests.cs b/tests/ModelContextProtocol.Tests/Server/AlternateResultFilterTests.cs new file mode 100644 index 000000000..0a9ae72f6 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/AlternateResultFilterTests.cs @@ -0,0 +1,206 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace ModelContextProtocol.Tests.Server; + +#pragma warning disable MCPEXP002 // exercises the experimental alternate-result seam + +/// +/// An alternate result a filter can return in place of a prompt result, standing in for the kind of +/// result a future task-enabled method would produce. +/// +internal sealed class AlternatePromptResult : Result +{ + [JsonPropertyName("note")] + public string? Note { get; set; } +} + +[JsonSerializable(typeof(AlternatePromptResult))] +internal sealed partial class AlternateResultJsonContext : JsonSerializerContext; + +/// +/// Verifies that the alternate-result seam is method-keyed rather than tools/call-shaped, by +/// driving it through and . +/// +public class AlternateResultFilterPromptTests(ITestOutputHelper testOutputHelper) : ClientServerTestBase(testOutputHelper) +{ + private static readonly JsonSerializerOptions s_serializerOptions = CreateSerializerOptions(); + + private readonly List _promptsListFilterOrder = []; + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.Services.Configure(options => + { + options.Capabilities = new() { Prompts = new() }; + + options.Handlers.ListPromptsHandler = static (_, _) => new(new ListPromptsResult + { + Prompts = [new Prompt { Name = "ordinary-prompt" }], + }); + + options.Handlers.GetPromptHandler = static (request, _) => new(new GetPromptResult + { + Description = $"ordinary:{request.Params?.Name}", + }); + + options.AddAlternateResultFilter( + RequestMethods.PromptsGet, + async (request, next, cancellationToken) => + { + if (request.Params?.Name == "alternate-prompt") + { + return ResultOrAlternate.FromAlternate( + new AlternatePromptResult { Note = "replaced by filter" }, + AlternateResultJsonContext.Default.AlternatePromptResult); + } + + return await next(request, cancellationToken); + }); + + options.AddAlternateResultFilter( + RequestMethods.PromptsList, + async (request, next, cancellationToken) => + { + _promptsListFilterOrder.Add("outer"); + return await next(request, cancellationToken); + }); + + options.AddAlternateResultFilter( + RequestMethods.PromptsList, + async (request, next, cancellationToken) => + { + _promptsListFilterOrder.Add("inner"); + return await next(request, cancellationToken); + }); + }); + } + + [Fact] + public async Task PromptsGet_AlternateFilter_ReturnsAlternateResultShape() + { + await using var client = await CreateMcpClientForServer(); + + var result = await client.SendRequestAsync( + RequestMethods.PromptsGet, + new GetPromptRequestParams { Name = "alternate-prompt" }, + serializerOptions: s_serializerOptions, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("replaced by filter", result.Note); + } + + [Fact] + public async Task PromptsGet_AlternateFilterDelegatingToNext_ReturnsOrdinaryResult() + { + await using var client = await CreateMcpClientForServer(); + + var result = await client.GetPromptAsync( + "ordinary-prompt", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("ordinary:ordinary-prompt", result.Description); + } + + [Fact] + public async Task PromptsList_AlternateFilters_RunInRegistrationOrder() + { + await using var client = await CreateMcpClientForServer(); + + var prompts = await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal("ordinary-prompt", Assert.Single(prompts).Name); + Assert.Equal(["outer", "inner"], _promptsListFilterOrder); + } + + private static JsonSerializerOptions CreateSerializerOptions() + { + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine( + AlternateResultJsonContext.Default, + McpJsonUtilities.DefaultOptions.TypeInfoResolver), + }; + + options.MakeReadOnly(); + return options; + } +} + +/// +/// Covers registration semantics of the method-keyed alternate-result store, including the +/// tools/call view and the errors raised for unusable registrations. +/// +public class AlternateResultFilterRegistrationTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper) +{ + private static McpRequestInvocationFilter> PassThroughToolFilter => + static (context, next, cancellationToken) => next(context, cancellationToken); + + [Fact] + public void CallToolWithAlternateFilters_IsViewOverToolsCallEntry() + { + var options = new McpServerOptions(); + + options.AddAlternateResultFilter(RequestMethods.ToolsCall, PassThroughToolFilter); + + Assert.Same( + options.Filters.Request.AlternateResultFilters.GetOrAdd(RequestMethods.ToolsCall), + options.Filters.Request.CallToolWithAlternateFilters); + Assert.Single(options.Filters.Request.CallToolWithAlternateFilters); + } + + [Fact] + public void GetOrAdd_WithMismatchedResultType_Throws() + { + var options = new McpServerOptions(); + options.AddAlternateResultFilter( + RequestMethods.PromptsGet, + static (context, next, cancellationToken) => next(context, cancellationToken)); + + var ex = Assert.Throws( + () => options.AddAlternateResultFilter( + RequestMethods.PromptsGet, + static (context, next, cancellationToken) => next(context, cancellationToken))); + + Assert.Contains(RequestMethods.PromptsGet, ex.Message); + Assert.Contains(nameof(GetPromptResult), ex.Message); + } + + [Fact] + public async Task Filters_ForMethodTheServerDoesNotDispatch_ThrowActionableError() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions { Capabilities = new() { Tools = new() } }; + + // No prompts capability is configured, so prompts/get is never registered. + options.AddAlternateResultFilter( + RequestMethods.PromptsGet, + static (context, next, cancellationToken) => next(context, cancellationToken)); + + var ex = Assert.Throws( + () => McpServer.Create(transport, options, LoggerFactory)); + + Assert.Contains(RequestMethods.PromptsGet, ex.Message); + Assert.Contains("does not", ex.Message); + } + + [Fact] + public async Task EmptyRegistration_ForUndispatchedMethod_DoesNotThrow() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions { Capabilities = new() { Tools = new() } }; + + // Touching the tools/call view without adding filters must not trip the validation. + _ = options.Filters.Request.CallToolWithAlternateFilters; + _ = options.Filters.Request.AlternateResultFilters.GetOrAdd(RequestMethods.PromptsGet); + + await using var server = McpServer.Create(transport, options, LoggerFactory); + + Assert.NotNull(server); + } +}