Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -33,6 +34,7 @@ public static IMcpServerBuilder WithHttpTransport(this IMcpServerBuilder builder
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)
{
Expand Down Expand Up @@ -64,4 +66,37 @@ public static IMcpServerBuilder AddAuthorizationFilters(this IMcpServerBuilder b

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;
}
}
20 changes: 20 additions & 0 deletions src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
@@ -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>();
}
}
7 changes: 3 additions & 4 deletions src/ModelContextProtocol.AspNetCore/StreamableHttpHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -255,7 +254,7 @@ await WriteJsonRpcErrorAsync(context,

private async ValueTask<StreamableHttpSession?> TryMigrateSessionAsync(HttpContext context, string sessionId)
{
if (sessionMigrationHandler is not { } handler)
if (HttpServerTransportOptions.SessionMigrationHandler is not { } handler)
{
return null;
}
Expand Down Expand Up @@ -336,7 +335,7 @@ private async ValueTask<StreamableHttpSession> StartNewSessionAsync(HttpContext
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,
};
Expand Down
8 changes: 6 additions & 2 deletions src/ModelContextProtocol/McpServerOptionsSetup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@
namespace ModelContextProtocol;

/// <summary>
/// Configures the McpServerOptions using addition services from DI.
/// Configures the McpServerOptions using additional services from DI.
/// </summary>
/// <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
Expand All @@ -23,6 +25,8 @@ public void Configure(McpServerOptions options)
{
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
Expand Down
11 changes: 0 additions & 11 deletions src/ModelContextProtocol/McpServerServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,6 @@ public static IMcpServerBuilder AddMcpServer(this IServiceCollection services, A
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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
using Microsoft.Extensions.Caching.Distributed;

namespace ModelContextProtocol.Server;

/// <summary>
/// Configuration options for <see cref="DistributedCacheEventStreamStore"/>.
/// </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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,18 @@ namespace ModelContextProtocol.AspNetCore.Tests;
/// </remarks>
public class DistributedCacheResumabilityIntegrationTests(ITestOutputHelper testOutputHelper) : ResumabilityIntegrationTestsBase(testOutputHelper)
{
private MemoryDistributedCache? _cache;

/// <inheritdoc />
protected override ValueTask<ISseEventStreamStore> CreateEventStreamStoreAsync()
{
// Create a new in-memory distributed cache for each test
_cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()));
var cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()));

// Configure the store with shorter expiration times suitable for testing
var options = new DistributedCacheEventStreamStoreOptions
{
// Use the in-memory distributed cache
Cache = cache,

// Use shorter polling interval for faster test execution
StreamReaderPollingInterval = TimeSpan.FromMilliseconds(50),

Expand All @@ -43,7 +44,7 @@ protected override ValueTask<ISseEventStreamStore> CreateEventStreamStoreAsync()
MetadataAbsoluteExpiration = TimeSpan.FromMinutes(10),
};

var store = new DistributedCacheEventStreamStore(_cache, options, LoggerFactory.CreateLogger<DistributedCacheEventStreamStore>());
var store = new DistributedCacheEventStreamStore(Options.Create(options), LoggerFactory.CreateLogger<DistributedCacheEventStreamStore>());
return new ValueTask<ISseEventStreamStore>(store);
}
}
Loading