diff --git a/Directory.Packages.props b/Directory.Packages.props
index b9a66c78b..a39fd3317 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -49,6 +49,12 @@
+
+
+
+
+
+
@@ -91,6 +97,8 @@
+
+
diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx
index 1090c5377..13fe0b833 100644
--- a/ModelContextProtocol.slnx
+++ b/ModelContextProtocol.slnx
@@ -65,12 +65,14 @@
+
+
diff --git a/README.md b/README.md
index 796f23f90..e18109181 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:
+This SDK consists of four main packages:
- **[ModelContextProtocol.Core](https://www.nuget.org/packages/ModelContextProtocol.Core)** [](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. [Documentation](src/ModelContextProtocol.Core/README.md)
@@ -14,6 +14,8 @@ This SDK consists of three main packages:
- **[ModelContextProtocol.AspNetCore](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore)** [](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore) - The library for HTTP-based MCP servers. References `ModelContextProtocol`. [Documentation](src/ModelContextProtocol.AspNetCore/README.md)
+- **[ModelContextProtocol.AspNetCore.Distributed](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore.Distributed/absoluteLatest)** [](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore.Distributed/absoluteLatest) - Session-aware routing for MCP servers running across multiple instances, built on ASP.NET Core HybridCache and YARP. [Documentation](src/ModelContextProtocol.AspNetCore.Distributed/README.md)
+
## Getting Started
To get started, see the [Getting Started](https://modelcontextprotocol.github.io/csharp-sdk/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.AspNetCore.Distributed/Abstractions/IListeningEndpointResolver.cs b/src/ModelContextProtocol.AspNetCore.Distributed/Abstractions/IListeningEndpointResolver.cs
new file mode 100644
index 000000000..ece15da33
--- /dev/null
+++ b/src/ModelContextProtocol.AspNetCore.Distributed/Abstractions/IListeningEndpointResolver.cs
@@ -0,0 +1,30 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Hosting.Server;
+
+namespace ModelContextProtocol.AspNetCore.Distributed.Abstractions;
+
+///
+/// Resolves the listening endpoint address for the local server instance
+/// that should be advertised to other instances for session affinity routing.
+///
+public interface IListeningEndpointResolver
+{
+ ///
+ /// Resolves the local server address that should be advertised to other instances
+ /// for session affinity routing.
+ ///
+ /// The server instance to resolve addresses from.
+ /// Configuration options containing explicit address overrides.
+ /// A normalized address string in the format "scheme://host:port".
+ ///
+ /// The resolution strategy is:
+ ///
+ /// If is set, validate and return it
+ /// Otherwise, resolve from server bindings, preferring non-localhost HTTP addresses
+ /// Fall back to http://localhost:80 if no addresses are available
+ ///
+ ///
+ string ResolveListeningEndpoint(IServer server, SessionAffinityOptions options);
+}
diff --git a/src/ModelContextProtocol.AspNetCore.Distributed/Abstractions/ISessionAffinityBuilder.cs b/src/ModelContextProtocol.AspNetCore.Distributed/Abstractions/ISessionAffinityBuilder.cs
new file mode 100644
index 000000000..1303b0ca9
--- /dev/null
+++ b/src/ModelContextProtocol.AspNetCore.Distributed/Abstractions/ISessionAffinityBuilder.cs
@@ -0,0 +1,17 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.DependencyInjection;
+
+namespace ModelContextProtocol.AspNetCore.Distributed.Abstractions;
+
+///
+/// A builder for configuring MCP session affinity.
+///
+public interface ISessionAffinityBuilder
+{
+ ///
+ /// Gets the host application builder.
+ ///
+ IServiceCollection Services { get; }
+}
diff --git a/src/ModelContextProtocol.AspNetCore.Distributed/Abstractions/ISessionStore.cs b/src/ModelContextProtocol.AspNetCore.Distributed/Abstractions/ISessionStore.cs
new file mode 100644
index 000000000..301f55cb0
--- /dev/null
+++ b/src/ModelContextProtocol.AspNetCore.Distributed/Abstractions/ISessionStore.cs
@@ -0,0 +1,31 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace ModelContextProtocol.AspNetCore.Distributed.Abstractions;
+
+///
+/// Provides persistence for MCP session ownership.
+///
+public interface ISessionStore
+{
+ ///
+ /// Gets the current owner of a session, or claims ownership if unclaimed.
+ ///
+ /// The session identifier.
+ /// A factory function that creates the owner information if the session is unclaimed.
+ /// Cancellation token.
+ /// The current or newly claimed owner information for the session.
+ Task GetOrClaimOwnershipAsync(
+ string sessionId,
+ Func> ownerInfoFactory,
+ CancellationToken cancellationToken = default
+ );
+
+ ///
+ /// Removes a session from the store.
+ ///
+ /// The session identifier to remove.
+ /// Cancellation token.
+ /// A task representing the asynchronous operation.
+ Task RemoveAsync(string sessionId, CancellationToken cancellationToken = default);
+}
diff --git a/src/ModelContextProtocol.AspNetCore.Distributed/Abstractions/SessionAffinityOptions.cs b/src/ModelContextProtocol.AspNetCore.Distributed/Abstractions/SessionAffinityOptions.cs
new file mode 100644
index 000000000..05ecb7892
--- /dev/null
+++ b/src/ModelContextProtocol.AspNetCore.Distributed/Abstractions/SessionAffinityOptions.cs
@@ -0,0 +1,103 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.ComponentModel.DataAnnotations;
+using Yarp.ReverseProxy.Configuration;
+using Yarp.ReverseProxy.Forwarder;
+
+namespace ModelContextProtocol.AspNetCore.Distributed.Abstractions;
+
+///
+/// Configuration options for MCP session affinity routing behavior.
+///
+public sealed class SessionAffinityOptions
+{
+ ///
+ /// Configuration for the YARP forwarder when routing requests to other silos.
+ /// If not set, a default configuration will be used.
+ ///
+ public ForwarderRequestConfig? ForwarderRequestConfig { get; set; }
+
+ ///
+ /// Configuration for the HTTP client used when forwarding requests to other silos.
+ /// If not set, an empty configuration will be used.
+ ///
+ public HttpClientConfig? HttpClientConfig { get; set; }
+
+ ///
+ /// The service key to use when resolving the service.
+ /// When set, the session store will use a keyed HybridCache service that can be configured
+ /// to use a specific distributed cache backend (e.g., Redis, SQL Server).
+ /// This enables scenarios where multiple cache instances are needed in a single application.
+ ///
+ ///
+ /// This property is used in conjunction with keyed HybridCache registration.
+ /// Register a keyed HybridCache instance using the standard DI keyed services APIs.
+ ///
+ public string? HybridCacheServiceKey { get; set; }
+
+ ///
+ /// Explicitly sets the local server address that will be advertised to other instances
+ /// for session affinity routing. This address is stored in the distributed session store
+ /// and used by other servers to forward requests back to this instance.
+ ///
+ ///
+ ///
+ /// When set, this value takes precedence over automatic address resolution from server bindings.
+ /// This is useful in scenarios where:
+ ///
+ /// Running in containerized environments where internal addresses differ from advertised addresses
+ /// Using service meshes where specific addresses/ports must be used for routing
+ /// Multiple network interfaces are available and a specific one should be used
+ /// Running behind load balancers or proxies with address translation
+ ///
+ ///
+ ///
+ /// The value must be a valid absolute URI including scheme (http or https), host, and port.
+ /// Examples:
+ ///
+ /// http://pod-1.mcp-service.default.svc.cluster.local:8080
+ /// http://10.0.1.5:5000
+ /// https://server1.internal:443
+ ///
+ ///
+ ///
+ /// If not set, the address will be automatically resolved from the server's configured
+ /// bindings, preferring HTTP over HTTPS for service mesh scenarios.
+ ///
+ ///
+ [HttpOrHttpsUri]
+ public string? LocalServerAddress { get; set; }
+}
+
+///
+/// Validates that a string is a valid HTTP or HTTPS URI.
+///
+[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter)]
+internal sealed class HttpOrHttpsUriAttribute : ValidationAttribute
+{
+ protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
+ {
+ if (value is null or string { Length: 0 })
+ {
+ return ValidationResult.Success;
+ }
+
+ if (value is not string stringValue)
+ {
+ return new ValidationResult("Value must be a string.");
+ }
+
+ if (!Uri.TryCreate(stringValue, UriKind.Absolute, out Uri? uri))
+ {
+ return new ValidationResult($"'{stringValue}' is not a valid absolute URI.");
+ }
+
+ if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
+ {
+ return new ValidationResult($"URI must use HTTP or HTTPS scheme. Found: {uri.Scheme}");
+ }
+
+ return ValidationResult.Success;
+ }
+}
diff --git a/src/ModelContextProtocol.AspNetCore.Distributed/Abstractions/SessionAffinityOptionsValidator.cs b/src/ModelContextProtocol.AspNetCore.Distributed/Abstractions/SessionAffinityOptionsValidator.cs
new file mode 100644
index 000000000..fe46a205a
--- /dev/null
+++ b/src/ModelContextProtocol.AspNetCore.Distributed/Abstractions/SessionAffinityOptionsValidator.cs
@@ -0,0 +1,16 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.Options;
+
+namespace ModelContextProtocol.AspNetCore.Distributed.Abstractions;
+
+///
+/// Validator for that ensures configuration is valid.
+/// Uses compile-time code generation for AOT compatibility.
+/// The source generator will automatically validate data annotations on the options class.
+///
+[OptionsValidator]
+internal sealed partial class SessionAffinityOptionsValidator
+ : IValidateOptions
+{ }
diff --git a/src/ModelContextProtocol.AspNetCore.Distributed/Abstractions/SessionOwnerInfo.cs b/src/ModelContextProtocol.AspNetCore.Distributed/Abstractions/SessionOwnerInfo.cs
new file mode 100644
index 000000000..e32abf2d1
--- /dev/null
+++ b/src/ModelContextProtocol.AspNetCore.Distributed/Abstractions/SessionOwnerInfo.cs
@@ -0,0 +1,19 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace ModelContextProtocol.AspNetCore.Distributed.Abstractions;
+
+///
+/// Identifies which server currently owns a session.
+///
+public sealed record SessionOwnerInfo
+{
+ /// Unique identifier for the owner (server id, instance id, etc.).
+ public required string OwnerId { get; init; }
+
+ /// Address (host[:port]) requests should be forwarded to.
+ public required string Address { get; init; }
+
+ /// Timestamp showing when the owner claimed this session.
+ public DateTimeOffset? ClaimedAt { get; init; }
+}
diff --git a/src/ModelContextProtocol.AspNetCore.Distributed/HybridCacheSessionStore.cs b/src/ModelContextProtocol.AspNetCore.Distributed/HybridCacheSessionStore.cs
new file mode 100644
index 000000000..d838699d3
--- /dev/null
+++ b/src/ModelContextProtocol.AspNetCore.Distributed/HybridCacheSessionStore.cs
@@ -0,0 +1,125 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.Caching.Hybrid;
+using Microsoft.Extensions.Logging;
+using ModelContextProtocol.AspNetCore.Distributed.Abstractions;
+
+namespace ModelContextProtocol.AspNetCore.Distributed;
+
+///
+/// HybridCache-backed implementation of .
+/// This implementation provides distributed session ownership across multiple servers
+/// using HybridCache, which combines in-memory and distributed caching for optimal performance.
+/// Sessions are stored with a configurable expiration time (default: 15 minutes).
+///
+///
+/// HybridCache provides several advantages over IDistributedCache:
+/// - Automatic serialization/deserialization
+/// - Built-in stampede protection
+/// - L1 (in-memory) + L2 (distributed) caching for better performance
+/// - Tag-based cache invalidation support
+///
+internal sealed class HybridCacheSessionStore : ISessionStore
+{
+ private static readonly TimeSpan DefaultSessionTimeout = TimeSpan.FromMinutes(15);
+ private readonly HybridCache _cache;
+ private readonly ILogger _logger;
+ private readonly HybridCacheEntryOptions _cacheEntryOptions;
+
+ public HybridCacheSessionStore(
+ HybridCache cache,
+ ILogger logger,
+ TimeSpan? sessionTimeout = null
+ )
+ {
+ _cache = cache;
+ _logger = logger;
+ var resolvedSessionTimeout = sessionTimeout ?? DefaultSessionTimeout;
+ _cacheEntryOptions = new()
+ {
+ Expiration = resolvedSessionTimeout,
+ // Allow L1 cache to expire sooner for better memory management
+ LocalCacheExpiration = TimeSpan.FromMinutes(
+ Math.Min(resolvedSessionTimeout.TotalMinutes / 2, 5)
+ ),
+ };
+ }
+
+ public async Task GetOrClaimOwnershipAsync(
+ string sessionId,
+ Func> ownerInfoFactory,
+ CancellationToken cancellationToken = default
+ )
+ {
+ ArgumentNullException.ThrowIfNull(sessionId);
+ ArgumentNullException.ThrowIfNull(ownerInfoFactory);
+
+ var key = $"mcp:session:{sessionId}";
+
+ try
+ {
+ // Track whether we created a new entry or retrieved an existing one
+ var wasCreated = false;
+
+ // HybridCache.GetOrCreateAsync will check L1 (memory) first, then L2 (distributed)
+ // If not found, it will call the factory to create and cache the value
+ var owner = await _cache.GetOrCreateAsync(
+ key,
+ async cancel =>
+ {
+ wasCreated = true;
+
+ // Call the provided factory to create the owner info
+ var ownerInfo = await ownerInfoFactory(cancel);
+
+ _logger.SessionClaimed(sessionId, ownerInfo.OwnerId);
+
+ return ownerInfo;
+ },
+ options: _cacheEntryOptions,
+ cancellationToken: cancellationToken
+ );
+
+ // HybridCache uses absolute expiration. We need to implement sliding expiration manually
+ // by re-setting the value with a new expiration time on each access.
+ // Only refresh if we retrieved an existing entry (not if we just created it).
+ if (!wasCreated)
+ {
+ await _cache.SetAsync(
+ key,
+ owner,
+ _cacheEntryOptions,
+ cancellationToken: cancellationToken
+ );
+ }
+
+ _logger.SessionOwnerRetrieved(sessionId, owner.OwnerId);
+
+ return owner;
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _logger.FailedToRetrieveSessionOwner(sessionId, ex);
+ throw;
+ }
+ }
+
+ public async Task RemoveAsync(string sessionId, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(sessionId);
+
+ var key = $"mcp:session:{sessionId}";
+
+ try
+ {
+ await _cache.RemoveAsync(key, cancellationToken);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _logger.FailedToRemoveSession(sessionId, ex);
+ // Don't rethrow - session removal is a best-effort cleanup operation
+ // The session will expire naturally if removal fails
+ }
+ }
+}
diff --git a/src/ModelContextProtocol.AspNetCore.Distributed/ListeningEndpointResolver.cs b/src/ModelContextProtocol.AspNetCore.Distributed/ListeningEndpointResolver.cs
new file mode 100644
index 000000000..5d7d5eac3
--- /dev/null
+++ b/src/ModelContextProtocol.AspNetCore.Distributed/ListeningEndpointResolver.cs
@@ -0,0 +1,145 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Net;
+using Microsoft.AspNetCore.Hosting.Server;
+using Microsoft.AspNetCore.Hosting.Server.Features;
+using ModelContextProtocol.AspNetCore.Distributed.Abstractions;
+
+namespace ModelContextProtocol.AspNetCore.Distributed;
+
+///
+/// Default implementation of that resolves
+/// the local server listening endpoint from explicit configuration or server bindings.
+///
+internal sealed class ListeningEndpointResolver : IListeningEndpointResolver
+{
+ ///
+ public string ResolveListeningEndpoint(IServer server, SessionAffinityOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(server);
+ ArgumentNullException.ThrowIfNull(options);
+
+ // Use explicit configuration if provided
+ if (!string.IsNullOrWhiteSpace(options.LocalServerAddress))
+ {
+ return ValidateAndNormalizeAddress(options.LocalServerAddress);
+ }
+
+ // Resolve from server bindings
+ return ResolveFromServerBindings(server);
+ }
+
+ private static string ValidateAndNormalizeAddress(string address)
+ {
+ if (!Uri.TryCreate(address, UriKind.Absolute, out var uri))
+ {
+ throw new ArgumentException(
+ $"LocalServerAddress '{address}' is not a valid absolute URI. "
+ + "It must include the scheme (http or https), host, and port (e.g., 'http://localhost:5000').",
+ nameof(address)
+ );
+ }
+
+ if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
+ {
+ throw new ArgumentException(
+ $"LocalServerAddress '{address}' must use either 'http' or 'https' scheme. "
+ + $"Got '{uri.Scheme}' instead.",
+ nameof(address)
+ );
+ }
+
+ // Normalize the address to include scheme, host, and port
+ // Remove any path, query, or fragment components as they're not needed for forwarding
+ var normalizedAddress = $"{uri.Scheme}://{uri.Host}:{uri.Port}";
+
+ return normalizedAddress;
+ }
+
+ private static string ResolveFromServerBindings(IServer server)
+ {
+ var addressesFeature = server.Features.Get();
+ if (addressesFeature is null || addressesFeature.Addresses.Count == 0)
+ {
+ // Fallback to http://localhost:80 if no addresses are available
+ return "http://localhost:80";
+ }
+
+ Uri? httpUri = null;
+ Uri? httpsUri = null;
+ Uri? localhostHttpUri = null;
+ Uri? localhostHttpsUri = null;
+
+ foreach (var address in addressesFeature.Addresses)
+ {
+ if (Uri.TryCreate(address, UriKind.Absolute, out var uri))
+ {
+ bool isLocalhost = IsLocalhostAddress(uri.Host);
+
+ if (uri.Scheme == "http")
+ {
+ if (isLocalhost)
+ {
+ localhostHttpUri ??= uri;
+ }
+ else
+ {
+ httpUri ??= uri;
+ }
+ }
+ else if (uri.Scheme == "https")
+ {
+ if (isLocalhost)
+ {
+ localhostHttpsUri ??= uri;
+ }
+ else
+ {
+ httpsUri ??= uri;
+ }
+ }
+ }
+ }
+
+ // Prefer external interfaces over localhost for reachability from other servers
+ // Prefer HTTP for internal routing in service mesh scenarios
+ // In service meshes, internal traffic is typically HTTP while external is HTTPS
+ var selectedUri = httpUri ?? httpsUri ?? localhostHttpUri ?? localhostHttpsUri;
+ if (selectedUri is null)
+ {
+ // Fallback if no valid URI found
+ return "http://localhost:80";
+ }
+
+ // Build address string in format "scheme://host:port"
+ var host = selectedUri.Host;
+ var port = selectedUri.Port;
+ var scheme = selectedUri.Scheme;
+
+ return $"{scheme}://{host}:{port}";
+ }
+
+ private static bool IsLocalhostAddress(string host)
+ {
+ // Check for common localhost representations
+ if (
+ string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)
+ || host.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(host, "127.0.0.1", StringComparison.Ordinal)
+ || string.Equals(host, "::1", StringComparison.Ordinal)
+ || string.Equals(host, "[::1]", StringComparison.Ordinal)
+ )
+ {
+ return true;
+ }
+
+ // Try to parse as IP address and check if it's loopback
+ if (IPAddress.TryParse(host, out var ipAddress))
+ {
+ return IPAddress.IsLoopback(ipAddress);
+ }
+
+ return false;
+ }
+}
diff --git a/src/ModelContextProtocol.AspNetCore.Distributed/MapSessionAffinityExtensions.cs b/src/ModelContextProtocol.AspNetCore.Distributed/MapSessionAffinityExtensions.cs
new file mode 100644
index 000000000..ab1b77453
--- /dev/null
+++ b/src/ModelContextProtocol.AspNetCore.Distributed/MapSessionAffinityExtensions.cs
@@ -0,0 +1,38 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace ModelContextProtocol.AspNetCore.Distributed;
+
+///
+/// Extension methods for adding session affinity to MCP endpoints.
+///
+public static class MapSessionAffinityExtensions
+{
+ ///
+ /// Adds session affinity to MCP endpoints.
+ /// This endpoint filter routes requests to the correct host based on session ownership.
+ /// Use this on the return value of MapMcp() to add session affinity routing.
+ /// Requires calling AddMcpHttpSessionAffinity() on the builder first.
+ ///
+ /// The endpoint convention builder from MapMcp().
+ /// Returns the builder for chaining additional configurations.
+ public static IEndpointConventionBuilder WithSessionAffinity(
+ this IEndpointConventionBuilder builder
+ )
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ builder.AddEndpointFilterFactory(
+ (routeHandlerContext, next) =>
+ {
+ var filter =
+ routeHandlerContext.ApplicationServices.GetRequiredService();
+ return (context) => filter.InvokeAsync(context, next);
+ }
+ );
+ return builder;
+ }
+}
diff --git a/src/ModelContextProtocol.AspNetCore.Distributed/ModelContextProtocol.AspNetCore.Distributed.csproj b/src/ModelContextProtocol.AspNetCore.Distributed/ModelContextProtocol.AspNetCore.Distributed.csproj
new file mode 100644
index 000000000..989fa32c6
--- /dev/null
+++ b/src/ModelContextProtocol.AspNetCore.Distributed/ModelContextProtocol.AspNetCore.Distributed.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net10.0
+ true
+ true
+ ModelContextProtocol.AspNetCore.Distributed
+ ASP.NET Core extensions for building enterprise-grade MCP servers that do not require external session affinity, yet work with session-aware features.
+ README.md
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ModelContextProtocol.AspNetCore.Distributed/README.md b/src/ModelContextProtocol.AspNetCore.Distributed/README.md
new file mode 100644
index 000000000..ebc30850c
--- /dev/null
+++ b/src/ModelContextProtocol.AspNetCore.Distributed/README.md
@@ -0,0 +1,116 @@
+# ModelContextProtocol.AspNetCore.Distributed
+
+Session-aware routing for Model Context Protocol (MCP) servers that need to run across multiple instances. This package builds on ASP.NET Core HybridCache and YARP so every MCP request reaches the server that owns the session state.
+
+## Why Use It
+
+- Keep in-memory session data (prompt history, tool context) with its owning instance
+- Scale stateful MCP servers horizontally without changing tool handlers
+- Forward requests automatically when the owning instance lives elsewhere
+- Plug in any `IDistributedCache` (Redis, SQL Server, NCache, etc.) for distributed storage
+
+## Install
+
+```bash
+dotnet add package ModelContextProtocol.AspNetCore.Distributed --prerelease
+```
+
+Add the distributed cache provider that matches your environment (for example `Microsoft.Extensions.Caching.StackExchangeRedis`).
+
+## Quick Start (Single Instance / Local Dev)
+
+```csharp
+using ModelContextProtocol.AspNetCore.Distributed;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services
+ .AddMcpServer()
+ .WithToolsFromAssembly()
+ .WithHttpTransport();
+
+builder.Services.AddMcpHttpSessionAffinity(); // Tracks ownership + routing
+
+var app = builder.Build();
+
+app.MapMcp()
+ .WithSessionAffinity(); // Add this to enable session affinity routing
+
+app.Run();
+```
+
+No distributed cache is required until you add additional instances.
+
+## Production Checklist
+
+1. Register an L2 cache (Redis + Azure AD auth is the most battle-tested option).
+2. Set `LocalServerAddress` to the routable address other replicas use (scheme, host, port).
+3. Tune `ForwarderRequestConfig` and `HttpClientConfig` for your downstream SLAs.
+4. Use `DefaultAzureCredential` locally and deployment-specific credentials in production.
+5. Monitor HybridCache hit rate and distributed cache availability for early warning.
+
+### Minimal Redis Configuration
+
+```csharp
+using Azure.Identity;
+using Microsoft.Extensions.Caching.StackExchangeRedis;
+using StackExchange.Redis;
+
+var redisCredential = builder.Environment.IsDevelopment()
+ ? new DefaultAzureCredential()
+ : new ManagedIdentityCredential();
+
+var endpoint = builder.Configuration["Redis:Endpoint"]
+ ?? throw new InvalidOperationException("Redis:Endpoint is required.");
+
+var redisConfig = await ConfigurationOptions
+ .Parse(endpoint)
+ .ConfigureForAzureWithTokenCredentialAsync(redisCredential);
+
+redisConfig.Ssl = true; // Always require TLS in production
+
+builder.Services.AddStackExchangeRedisCache(options =>
+{
+ options.ConfigurationOptions = redisConfig;
+ options.InstanceName = "MCP:";
+});
+
+builder.Services.AddMcpHttpSessionAffinity(options =>
+{
+ options.LocalServerAddress = builder.Configuration["Server:InternalAddress"]
+ ?? throw new InvalidOperationException("Server:InternalAddress is required.");
+});
+```
+
+`appsettings.json`
+
+```json
+{
+ "Redis": {
+ "Endpoint": "your-mcp-session-affinity.region.redis.azure.net:6380"
+ },
+ "Server": {
+ "InternalAddress": "http://pod-1.mcp.default.svc.cluster.local:8080"
+ }
+}
+```
+
+## Core Concepts
+
+- Session ownership: the first request with `Mcp-Session-Id` (header) or `sessionId` (query) claims the session and stores ownership in HybridCache.
+- HybridCache tiers: L1 memory cache plus optional L2 distributed cache; tune expiration to control how long ownership survives inactivity.
+- Forwarding: if the current node is not the owner, YARP forwards the request to the owning instance over HTTP(S).
+- Stale detection: when an owning instance restarts, the affinity entry is discarded so clients can establish a fresh session and rebuild state.
+
+## Configuration Reference
+
+- `SessionAffinityOptions.LocalServerAddress`: required in multi-instance environments; must be a routable absolute URI.
+- `ForwarderRequestConfig`: controls forwarding timeout, buffering, and HTTP version.
+- `HttpClientConfig`: tune connection pooling for heavy cross-node routing.
+- `HybridCacheOptions`: set `DefaultEntryOptions.Expiration` (L2) and `LocalCacheExpiration` (L1) to balance freshness versus resilience.
+
+## Observability
+
+- Enable `ModelContextProtocol.AspNetCore.Distributed` logs at `Information` by default and `Debug` for routing traces.
+- Watch for `ResolvingSessionOwner`, `SessionEstablished`, and `ForwardingRequest` events to understand ownership decisions.
+- Export HybridCache hit/miss metrics to confirm cache sizing and detect unusual churn.
diff --git a/src/ModelContextProtocol.AspNetCore.Distributed/SemanticLogging.cs b/src/ModelContextProtocol.AspNetCore.Distributed/SemanticLogging.cs
new file mode 100644
index 000000000..412222282
--- /dev/null
+++ b/src/ModelContextProtocol.AspNetCore.Distributed/SemanticLogging.cs
@@ -0,0 +1,262 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.Logging;
+
+namespace ModelContextProtocol.AspNetCore.Distributed;
+
+///
+/// Defines semantic logging event IDs for the session state management module.
+/// These IDs enable filtering, categorization, and structured logging across all session-related operations.
+/// Uses a high base number (50000) to avoid conflicts with other libraries.
+///
+internal enum LogEventId
+{
+ ///
+ /// Resolving the owner of an existing session. Event ID: 50001
+ ///
+ ResolvingSessionOwner = 50001,
+
+ ///
+ /// A new session has been claimed by an owner. Event ID: 50002
+ ///
+ SessionClaimed = 50002,
+
+ ///
+ /// Session is already owned by another server instance. Event ID: 50003
+ ///
+ SessionOwnedByOther = 50003,
+
+ ///
+ /// Failed to deserialize session owner information from cache. Event ID: 50004
+ ///
+ FailedToDeserializeSessionOwner = 50004,
+
+ ///
+ /// Session has been established on the current host. Event ID: 50100
+ ///
+ SessionEstablished = 50100,
+
+ ///
+ /// Forwarding a request to another server that owns the session. Event ID: 50101
+ ///
+ ForwardingRequest = 50101,
+
+ ///
+ /// Session owner information retrieved from cache. Event ID: 50102
+ ///
+ SessionOwnerRetrieved = 50102,
+
+ ///
+ /// Failed to retrieve session owner information from cache. Event ID: 50103
+ ///
+ FailedToRetrieveSessionOwner = 50103,
+
+ ///
+ /// Removing stale session after receiving 404 from remote endpoint. Event ID: 50104
+ ///
+ RemovingStaleSession = 50104,
+
+ ///
+ /// Failed to remove session from cache. Event ID: 50105
+ ///
+ FailedToRemoveSession = 50105,
+
+ ///
+ /// Removing a stale session that points to the local address but has an outdated OwnerId. Event ID: 50106
+ ///
+ RemovingStaleLocalSession = 50106,
+}
+
+///
+/// Semantic logging methods for session state management operations.
+/// Uses structured logging with compile-time code generation via LoggerMessage attributes.
+///
+internal static partial class SemanticLogging
+{
+ ///
+ /// Logs when resolving the owner of an existing session.
+ ///
+ /// The logger instance.
+ /// The session identifier.
+ /// The proposed owner identifier.
+ [LoggerMessage(
+ EventId = (int)LogEventId.ResolvingSessionOwner,
+ Level = LogLevel.Debug,
+ Message = "Resolving session owner for session {SessionId}, proposed owner: {OwnerId}"
+ )]
+ public static partial void ResolvingSessionOwner(
+ this ILogger logger,
+ string sessionId,
+ string ownerId
+ );
+
+ ///
+ /// Logs when a new session is claimed by the current server instance.
+ ///
+ /// The logger instance.
+ /// The session identifier.
+ /// The owner identifier of this server instance.
+ [LoggerMessage(
+ EventId = (int)LogEventId.SessionClaimed,
+ Level = LogLevel.Debug,
+ Message = "Session {SessionId} claimed by owner {OwnerId}"
+ )]
+ public static partial void SessionClaimed(
+ this ILogger logger,
+ string sessionId,
+ string ownerId
+ );
+
+ ///
+ /// Logs when an existing session is found to be owned by another server instance.
+ ///
+ /// The logger instance.
+ /// The session identifier.
+ /// The owner identifier of the server instance that owns the session.
+ [LoggerMessage(
+ EventId = (int)LogEventId.SessionOwnedByOther,
+ Level = LogLevel.Debug,
+ Message = "Session {SessionId} already owned by {OwnerId}"
+ )]
+ public static partial void SessionOwnedByOther(
+ this ILogger logger,
+ string sessionId,
+ string ownerId
+ );
+
+ ///
+ /// Logs when deserialization of session owner information fails.
+ /// This can occur if the cached data is corrupted or in an unexpected format.
+ ///
+ /// The logger instance.
+ /// The session identifier.
+ /// The exception that occurred during deserialization.
+ [LoggerMessage(
+ EventId = (int)LogEventId.FailedToDeserializeSessionOwner,
+ Level = LogLevel.Warning,
+ Message = "Failed to deserialize session owner for session {SessionId}"
+ )]
+ public static partial void FailedToDeserializeSessionOwner(
+ this ILogger logger,
+ string sessionId,
+ Exception ex
+ );
+
+ ///
+ /// Logs when a session is established on the current server instance.
+ ///
+ /// The logger instance.
+ /// The session identifier.
+ [LoggerMessage(
+ EventId = (int)LogEventId.SessionEstablished,
+ Level = LogLevel.Information,
+ Message = "Session established for session {SessionId} on this host"
+ )]
+ public static partial void SessionEstablished(this ILogger logger, string sessionId);
+
+ ///
+ /// Logs when a request is being forwarded to another server instance that owns the session.
+ ///
+ /// The logger instance.
+ /// The destination server prefix (scheme://host:port).
+ /// The session identifier.
+ [LoggerMessage(
+ EventId = (int)LogEventId.ForwardingRequest,
+ Level = LogLevel.Information,
+ Message = "Forwarding request to {DestinationPrefix} for session {SessionId}"
+ )]
+ public static partial void ForwardingRequest(
+ this ILogger logger,
+ string destinationPrefix,
+ string sessionId
+ );
+
+ ///
+ /// Logs when session owner information is retrieved from the session store.
+ ///
+ /// The logger instance.
+ /// The session identifier.
+ /// The owner identifier of the retrieved session.
+ [LoggerMessage(
+ EventId = (int)LogEventId.SessionOwnerRetrieved,
+ Level = LogLevel.Debug,
+ Message = "Retrieved session owner {OwnerId} for session {SessionId}"
+ )]
+ public static partial void SessionOwnerRetrieved(
+ this ILogger logger,
+ string sessionId,
+ string ownerId
+ );
+
+ ///
+ /// Logs when retrieval of session owner information fails.
+ /// This can occur if there are cache connectivity issues or other errors.
+ ///
+ /// The logger instance.
+ /// The session identifier.
+ /// The exception that occurred during retrieval.
+ [LoggerMessage(
+ EventId = (int)LogEventId.FailedToRetrieveSessionOwner,
+ Level = LogLevel.Warning,
+ Message = "Failed to retrieve session owner for session {SessionId}"
+ )]
+ public static partial void FailedToRetrieveSessionOwner(
+ this ILogger logger,
+ string sessionId,
+ Exception ex
+ );
+
+ ///
+ /// Logs when removing a stale session after receiving a 404 from the remote endpoint.
+ /// This indicates the remote server no longer has the session, likely due to a process restart.
+ ///
+ /// The logger instance.
+ /// The session identifier.
+ /// The owner identifier of the server that returned 404.
+ [LoggerMessage(
+ EventId = (int)LogEventId.RemovingStaleSession,
+ Level = LogLevel.Warning,
+ Message = "Removing stale session {SessionId} after 404 response from owner {OwnerId}"
+ )]
+ public static partial void RemovingStaleSession(
+ this ILogger logger,
+ string sessionId,
+ string ownerId
+ );
+
+ ///
+ /// Logs when removal of a session from the cache fails.
+ ///
+ /// The logger instance.
+ /// The session identifier.
+ /// The exception that occurred during removal.
+ [LoggerMessage(
+ EventId = (int)LogEventId.FailedToRemoveSession,
+ Level = LogLevel.Warning,
+ Message = "Failed to remove session {SessionId} from cache"
+ )]
+ public static partial void FailedToRemoveSession(
+ this ILogger logger,
+ string sessionId,
+ Exception ex
+ );
+
+ ///
+ /// Logs when removing a stale session record that references this host with an outdated OwnerId.
+ /// This occurs when the application restarts and generates a new OwnerId, making the previous session unusable.
+ ///
+ /// The logger instance.
+ /// The session identifier.
+ /// The stale owner identifier from the cache.
+ [LoggerMessage(
+ EventId = (int)LogEventId.RemovingStaleLocalSession,
+ Level = LogLevel.Warning,
+ Message = "Removing stale session {SessionId} owned by previous instance {OldOwnerId}"
+ )]
+ public static partial void RemovingStaleLocalSession(
+ this ILogger logger,
+ string sessionId,
+ string oldOwnerId
+ );
+}
diff --git a/src/ModelContextProtocol.AspNetCore.Distributed/SerializerContext.cs b/src/ModelContextProtocol.AspNetCore.Distributed/SerializerContext.cs
new file mode 100644
index 000000000..f93947ed9
--- /dev/null
+++ b/src/ModelContextProtocol.AspNetCore.Distributed/SerializerContext.cs
@@ -0,0 +1,17 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json.Serialization;
+using ModelContextProtocol.AspNetCore.Distributed.Abstractions;
+
+namespace ModelContextProtocol.AspNetCore.Distributed;
+
+///
+/// JSON serialization context for distributed session store.
+///
+[JsonSourceGenerationOptions(
+ PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+)]
+[JsonSerializable(typeof(SessionOwnerInfo))]
+internal sealed partial class SerializerContext : JsonSerializerContext { }
diff --git a/src/ModelContextProtocol.AspNetCore.Distributed/ServiceCollectionExtensions.cs b/src/ModelContextProtocol.AspNetCore.Distributed/ServiceCollectionExtensions.cs
new file mode 100644
index 000000000..459977926
--- /dev/null
+++ b/src/ModelContextProtocol.AspNetCore.Distributed/ServiceCollectionExtensions.cs
@@ -0,0 +1,77 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.Caching.Hybrid;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using ModelContextProtocol.AspNetCore.Distributed.Abstractions;
+
+namespace ModelContextProtocol.AspNetCore.Distributed;
+
+///
+/// Extension methods for configuring MCP session affinity services.
+///
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// Adds the required services for MCP session affinity.
+ /// This includes YARP reverse proxy and the session affinity routing filter.
+ /// Uses HybridCache for session storage (L1 memory + L2 distributed caching).
+ ///
+ /// The host service collection.
+ /// Optional action to configure SessionAffinityOptions.
+ /// A builder for configuring MCP session affinity.
+ public static ISessionAffinityBuilder AddMcpHttpSessionAffinity(
+ this IServiceCollection services,
+ Action? configure = null
+ )
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ // Configure options using the options pattern
+ if (configure is not null)
+ {
+ services.Configure(configure);
+ }
+
+ // Add validation for SessionAffinityOptions using source-generated validator
+ services.TryAddSingleton<
+ IValidateOptions,
+ SessionAffinityOptionsValidator
+ >();
+
+ // Register HybridCache with default configuration
+ // This provides L1 (in-memory) + L2 (distributed) caching
+ // Consumers can add their own distributed cache (Redis, SQL Server, etc.)
+ // via AddStackExchangeRedisCache, AddDistributedSqlServerCache, etc.
+ // Use source-generated serialization for SessionOwnerInfo (AOT-compatible)
+ services
+ .AddHybridCache()
+ .AddSerializer();
+
+ // Register HybridCache session store
+ services.TryAdd(
+ ServiceDescriptor.Singleton(sp =>
+ {
+ var options = sp.GetRequiredService>().Value;
+ var cache = options.HybridCacheServiceKey is null
+ ? sp.GetRequiredService()
+ : sp.GetRequiredKeyedService(options.HybridCacheServiceKey);
+ var logger = sp.GetRequiredService>();
+ return new HybridCacheSessionStore(cache, logger);
+ })
+ );
+
+ services.TryAddSingleton();
+
+ // Add YARP reverse proxy for request forwarding
+ services.AddReverseProxy();
+
+ // Register the endpoint filter for dependency injection
+ services.TryAddSingleton();
+
+ return new SessionAffinityBuilder(services);
+ }
+}
diff --git a/src/ModelContextProtocol.AspNetCore.Distributed/SessionAffinityBuilder.cs b/src/ModelContextProtocol.AspNetCore.Distributed/SessionAffinityBuilder.cs
new file mode 100644
index 000000000..38673d397
--- /dev/null
+++ b/src/ModelContextProtocol.AspNetCore.Distributed/SessionAffinityBuilder.cs
@@ -0,0 +1,12 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol.AspNetCore.Distributed.Abstractions;
+
+namespace ModelContextProtocol.AspNetCore.Distributed;
+
+internal sealed class SessionAffinityBuilder(IServiceCollection services) : ISessionAffinityBuilder
+{
+ public IServiceCollection Services { get; } = services;
+}
diff --git a/src/ModelContextProtocol.AspNetCore.Distributed/SessionAffinityEndpointFilter.cs b/src/ModelContextProtocol.AspNetCore.Distributed/SessionAffinityEndpointFilter.cs
new file mode 100644
index 000000000..74dc6ad75
--- /dev/null
+++ b/src/ModelContextProtocol.AspNetCore.Distributed/SessionAffinityEndpointFilter.cs
@@ -0,0 +1,228 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Hosting.Server;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using ModelContextProtocol.AspNetCore.Distributed.Abstractions;
+using Yarp.ReverseProxy.Configuration;
+using Yarp.ReverseProxy.Forwarder;
+
+namespace ModelContextProtocol.AspNetCore.Distributed;
+
+///
+/// Endpoint filter that implements session affinity for MCP requests.
+/// Routes requests to the server that owns the session, or handles locally if this is the owner.
+///
+internal sealed class SessionAffinityEndpointFilter : IEndpointFilter
+{
+ private const string McpSessionIdHeaderName = "Mcp-Session-Id";
+
+ private readonly ISessionStore _sessionStore;
+ private readonly string _localOwnerId;
+ private readonly IHttpForwarder _forwarder;
+ private readonly HttpMessageInvoker _httpClient;
+ private readonly ForwarderRequestConfig _forwarderRequestConfig;
+ private readonly ILogger _logger;
+ private readonly string _localAddress;
+
+ public SessionAffinityEndpointFilter(
+ ISessionStore sessionStore,
+ IHttpForwarder forwarder,
+ IForwarderHttpClientFactory httpClientFactory,
+ IListeningEndpointResolver listeningEndpointResolver,
+ IServer server,
+ IOptions options,
+ ILogger logger
+ )
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ var optionsValue = options.Value;
+
+ _sessionStore = sessionStore;
+ // IMPORTANT: The OwnerId (_localOwnerId) is regenerated as a new GUID each time the application restarts.
+ // Session ownership data does not persist across restarts, so stale session entries are cleared when encountered.
+ _localOwnerId = Guid.NewGuid().ToString();
+ _forwarder = forwarder;
+ _httpClient = httpClientFactory.CreateClient(
+ new ForwarderHttpClientContext
+ {
+ NewConfig = optionsValue.HttpClientConfig ?? HttpClientConfig.Empty,
+ }
+ );
+ _forwarderRequestConfig =
+ optionsValue.ForwarderRequestConfig ?? ForwarderRequestConfig.Empty;
+ _logger = logger;
+
+ // Use the listening endpoint resolver to get the advertised address
+ // IServerAddressesFeature is populated before endpoint filters are created
+ // Note: LocalServerAddress can be set via IPostConfigureOptions for dynamic resolution
+ _localAddress = listeningEndpointResolver.ResolveListeningEndpoint(server, optionsValue);
+ }
+
+ public async ValueTask