Unify service configuration for ISseEventStreamStore, IMcpTaskStore, and ISessionMigrationHandler (#1362)

This commit is contained in:
Mackinnon Buck 2026-02-23 23:22:59 -08:00 • committed by GitHub
commit 669c36b229
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 462 additions and 80 deletions

View file

@ -0,0 +1,17 @@
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Server;
namespace ModelContextProtocol.AspNetCore;
/// <summary>
/// Configures <see cref="DistributedCacheEventStreamStoreOptions"/> by resolving
/// the <see cref="IDistributedCache"/> from DI when not explicitly set.
/// </summary>
internal sealed class DistributedCacheEventStreamStoreOptionsSetup(IDistributedCache? cache = null) : IConfigureOptions<DistributedCacheEventStreamStoreOptions>
{
public void Configure(DistributedCacheEventStreamStoreOptions options)
{
options.Cache ??= cache;
}
}

View file

@ -0,0 +1,25 @@
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Server;
namespace ModelContextProtocol.AspNetCore;
/// <summary>
/// Validates that <see cref="DistributedCacheEventStreamStoreOptions.Cache"/> is set.
/// </summary>
internal sealed class DistributedCacheEventStreamStoreOptionsValidator : IValidateOptions<DistributedCacheEventStreamStoreOptions>
{
public ValidateOptionsResult Validate(string? name, DistributedCacheEventStreamStoreOptions options)
{
if (options.Cache is null)
{
return ValidateOptionsResult.Fail(
$"The '{nameof(DistributedCacheEventStreamStoreOptions)}.{nameof(DistributedCacheEventStreamStoreOptions.Cache)}' property must be set. " +
$"Register an {nameof(IDistributedCache)} in DI or set the {nameof(DistributedCacheEventStreamStoreOptions.Cache)} property " +
$"in the '{nameof(HttpMcpServerBuilderExtensions.WithDistributedCacheEventStreamStore)}' configure callback.");
}
return ValidateOptionsResult.Success;
}
}

View file

@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using ModelContextProtocol.AspNetCore;
@ -33,6 +34,7 @@ public static class HttpMcpServerBuilderExtensions
builder.Services.AddHostedService<IdleTrackingBackgroundService>();
builder.Services.TryAddEnumerable(ServiceDescriptor.Transient<IPostConfigureOptions<McpServerOptions>, AuthorizationFilterSetup>());
builder.Services.TryAddEnumerable(ServiceDescriptor.Transient<IConfigureOptions<HttpServerTransportOptions>, HttpServerTransportOptionsSetup>());
if (configureOptions is not null)
{
@ -64,4 +66,37 @@ public static class HttpMcpServerBuilderExtensions
return builder;
}
/// <summary>
/// Registers a <see cref="DistributedCacheEventStreamStore"/> as the <see cref="ISseEventStreamStore"/> for SSE resumability.
/// </summary>
/// <param name="builder">The builder instance.</param>
/// <param name="configureOptions">An optional action to configure <see cref="DistributedCacheEventStreamStoreOptions"/>.</param>
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <para>
/// An <see cref="IDistributedCache"/> implementation must be registered in the service collection before calling this method.
/// The registered cache is automatically assigned to <see cref="DistributedCacheEventStreamStoreOptions.Cache"/>.
/// </para>
/// <para>
/// To use a specific <see cref="IDistributedCache"/> instance instead of the one registered in DI,
/// set the <see cref="DistributedCacheEventStreamStoreOptions.Cache"/> property in the <paramref name="configureOptions"/> callback.
/// </para>
/// </remarks>
public static IMcpServerBuilder WithDistributedCacheEventStreamStore(this IMcpServerBuilder builder, Action<DistributedCacheEventStreamStoreOptions>? configureOptions = null)
{
ArgumentNullException.ThrowIfNull(builder);
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IConfigureOptions<DistributedCacheEventStreamStoreOptions>, DistributedCacheEventStreamStoreOptionsSetup>());
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<DistributedCacheEventStreamStoreOptions>, DistributedCacheEventStreamStoreOptionsValidator>());
builder.Services.AddSingleton<ISseEventStreamStore, DistributedCacheEventStreamStore>();
if (configureOptions is not null)
{
builder.Services.Configure(configureOptions);
}
return builder;
}
}

View file

@ -55,9 +55,29 @@ public class HttpServerTransportOptions
/// <item><description>Replay missed events when a client reconnects with a Last-Event-ID header</description></item>
/// <item><description>Send priming events to establish resumability before any actual messages</description></item>
/// </list>
/// <para>
/// This can be set directly, or an <see cref="ISseEventStreamStore"/> can be registered in DI.
/// If this property is not set, the server will attempt to resolve an <see cref="ISseEventStreamStore"/> from DI.
/// </para>
/// </remarks>
public ISseEventStreamStore? EventStreamStore { get; set; }
/// <summary>
/// Gets or sets the session migration handler for cross-instance session migration.
/// </summary>
/// <remarks>
/// <para>
/// When configured, the server will support session migration between instances.
/// If a request arrives with a session ID that is not found locally, the handler
/// is consulted to determine if the session can be migrated from another instance.
/// </para>
/// <para>
/// This can be set directly, or an <see cref="ISessionMigrationHandler"/> can be registered in DI.
/// If this property is not set, the server will attempt to resolve an <see cref="ISessionMigrationHandler"/> from DI.
/// </para>
/// </remarks>
public ISessionMigrationHandler? SessionMigrationHandler { get; set; }
/// <summary>
/// Gets or sets a value that indicates whether the server uses a single execution context for the entire session.
/// </summary>

View file

@ -0,0 +1,18 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Server;
namespace ModelContextProtocol.AspNetCore;
/// <summary>
/// Post-configures <see cref="HttpServerTransportOptions"/> by resolving services from DI
/// when they haven't been explicitly set on the options.
/// </summary>
internal sealed class HttpServerTransportOptionsSetup(IServiceProvider serviceProvider) : IConfigureOptions<HttpServerTransportOptions>
{
public void Configure(HttpServerTransportOptions options)
{
options.EventStreamStore ??= serviceProvider.GetService<ISseEventStreamStore>();
options.SessionMigrationHandler ??= serviceProvider.GetService<ISessionMigrationHandler>();
}
}

View file

@ -21,8 +21,7 @@ internal sealed class StreamableHttpHandler(
StatefulSessionManager sessionManager,
IHostApplicationLifetime hostApplicationLifetime,
IServiceProvider applicationServices,
ILoggerFactory loggerFactory,
ISessionMigrationHandler? sessionMigrationHandler = null)
ILoggerFactory loggerFactory)
{
private const string McpSessionIdHeaderName = "Mcp-Session-Id";
private const string McpProtocolVersionHeaderName = "MCP-Protocol-Version";
@ -255,7 +254,7 @@ internal sealed class StreamableHttpHandler(
private async ValueTask<StreamableHttpSession?> TryMigrateSessionAsync(HttpContext context, string sessionId)
{
if (sessionMigrationHandler is not { } handler)
if (HttpServerTransportOptions.SessionMigrationHandler is not { } handler)
{
return null;
}
@ -336,7 +335,7 @@ internal sealed class StreamableHttpHandler(
SessionId = sessionId,
FlowExecutionContextFromRequests = !HttpServerTransportOptions.PerSessionExecutionContext,
EventStreamStore = HttpServerTransportOptions.EventStreamStore,
OnSessionInitialized = sessionMigrationHandler is { } handler
OnSessionInitialized = HttpServerTransportOptions.SessionMigrationHandler is { } handler
? (initParams, ct) => handler.OnSessionInitializedAsync(context, sessionId, initParams, ct)
: null,
};

View file

@ -9,10 +9,12 @@ namespace ModelContextProtocol;
/// <param name="serverTools">The individually registered tools.</param>
/// <param name="serverPrompts">The individually registered prompts.</param>
/// <param name="serverResources">The individually registered resources.</param>
/// <param name="taskStore">The optional task store registered in DI.</param>
internal sealed class McpServerOptionsSetup(
IEnumerable<McpServerTool> serverTools,
IEnumerable<McpServerPrompt> serverPrompts,
IEnumerable<McpServerResource> serverResources) : IConfigureOptions<McpServerOptions>
IEnumerable<McpServerResource> serverResources,
IMcpTaskStore? taskStore = null) : IConfigureOptions<McpServerOptions>
{
/// <summary>
/// Configures the given McpServerOptions instance by setting server information
@ -23,6 +25,8 @@ internal sealed class McpServerOptionsSetup(
{
Throw.IfNull(options);
options.TaskStore ??= taskStore;
// Collect all of the provided tools into a tools collection. If the options already has
// a collection, add to it, otherwise create a new one. We want to maintain the identity
// of an existing collection in case someone has provided their own derived type, wants

View file

@ -26,17 +26,6 @@ public static class McpServerServiceCollectionExtensions
services.Configure(configureOptions);
}
// Register IMcpTaskStore from options if not already registered.
// This allows users to either:
// 1. Register IMcpTaskStore directly in DI (takes precedence)
// 2. Set options.TaskStore in the configuration callback (used as fallback)
// If neither is done, resolving IMcpTaskStore will throw.
services.TryAddSingleton<IMcpTaskStore>(sp =>
{
var options = sp.GetRequiredService<IOptions<McpServerOptions>>().Value;
return options.TaskStore ?? throw new InvalidOperationException("No IMcpTaskStore has been configured. Either register an IMcpTaskStore in the service collection or set McpServerOptions.TaskStore when configuring the MCP server.");
});
return new DefaultMcpServerBuilder(services);
}
}

View file

@ -1,6 +1,7 @@
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Protocol;
using System.Net.ServerSentEvents;
using System.Runtime.CompilerServices;
@ -31,14 +32,16 @@ public sealed partial class DistributedCacheEventStreamStore : ISseEventStreamSt
/// <summary>
/// Initializes a new instance of the <see cref="DistributedCacheEventStreamStore"/> class.
/// </summary>
/// <param name="cache">The distributed cache to use for storage.</param>
/// <param name="options">Optional configuration options for the store.</param>
/// <param name="options">Configuration options for the store, including the <see cref="IDistributedCache"/> to use.</param>
/// <param name="logger">Optional logger for diagnostic output.</param>
public DistributedCacheEventStreamStore(IDistributedCache cache, DistributedCacheEventStreamStoreOptions? options = null, ILogger<DistributedCacheEventStreamStore>? logger = null)
public DistributedCacheEventStreamStore(IOptions<DistributedCacheEventStreamStoreOptions> options, ILogger<DistributedCacheEventStreamStore>? logger = null)
{
Throw.IfNull(cache);
_cache = cache;
_options = options ?? new();
Throw.IfNull(options);
var optionsValue = options.Value;
_cache = optionsValue.Cache ?? throw new InvalidOperationException(
$"The '{nameof(DistributedCacheEventStreamStoreOptions)}.{nameof(DistributedCacheEventStreamStoreOptions.Cache)}' property must be set.");
_options = optionsValue;
_logger = logger ?? NullLogger<DistributedCacheEventStreamStore>.Instance;
}

View file

@ -1,3 +1,5 @@
using Microsoft.Extensions.Caching.Distributed;
namespace ModelContextProtocol.Server;
/// <summary>
@ -5,6 +7,16 @@ namespace ModelContextProtocol.Server;
/// </summary>
public sealed class DistributedCacheEventStreamStoreOptions
{
/// <summary>
/// Gets or sets the <see cref="IDistributedCache"/> to use for event storage.
/// </summary>
/// <remarks>
/// When using dependency injection with <c>WithDistributedCacheEventStreamStore()</c>, this is
/// automatically populated from the <see cref="IDistributedCache"/> registered in DI.
/// Set this property explicitly to use a specific cache instance.
/// </remarks>
public IDistributedCache? Cache { get; set; }
/// <summary>
/// Gets or sets the sliding expiration for individual events in the cache.
/// </summary>