From 983cdcaff0713d25bb7b0de344fa279521d5721d Mon Sep 17 00:00:00 2001 From: onatozmenn Date: Sat, 1 Aug 2026 03:20:47 +0300 Subject: [PATCH] Make the alternate-result seam method-agnostic The alternate-result augmentation seam is tools/call-shaped today, so enabling a second method to return an alternate result would mean another WithAlternateHandler/Filters pair plus new Core wiring each time. Add a method-keyed store (AlternateResultFilterCollection) and McpServerOptions.AddAlternateResultFilter(method, filter). CallToolWithAlternateFilters becomes a typed view over the tools/call entry of that store, so filters registered through either API share one ordered list and nothing existing changes shape. Only adaptation and filter composition are generalized. Primitive matching and tool-call lifecycle logging stay per-method, so tools/call keeps its own composition in BuildComposedCallToolHandler. Registering filters for a method the server does not dispatch throws at construction rather than silently dropping them. Signed-off-by: onatozmenn --- .../Server/AlternateResultFilterCollection.cs | 131 +++++++++++ .../Server/McpRequestFilters.cs | 26 ++- .../Server/McpServerImpl.cs | 136 +++++++++--- .../Server/McpServerOptions.cs | 36 +++ .../Server/AlternateResultFilterTests.cs | 206 ++++++++++++++++++ 5 files changed, 500 insertions(+), 35 deletions(-) create mode 100644 src/ModelContextProtocol.Core/Server/AlternateResultFilterCollection.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/AlternateResultFilterTests.cs 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); + } +}