Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
8 changes: 8 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.14.0" />
</ItemGroup>

<!-- Product dependencies for Distributed package -->
<ItemGroup>
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="9.5.0" />
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
</ItemGroup>

<!-- Source Generator & Analyzer dependencies -->
<ItemGroup>
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" />
Expand Down Expand Up @@ -91,6 +97,8 @@
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="$(System10Version)" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="NSubstitute" Version="5.3.0" />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They already have moq in this repo. I wouldn't be surprised if they wanted us to keep using that instead of NSubstitute.

<PackageVersion Include="NSubstitute.Analyzers.CSharp" Version="1.0.17" />
<PackageVersion Include="System.Net.Http" Version="4.3.4" />
<PackageVersion Include="JsonSchema.Net" Version="9.1.1" />
</ItemGroup>
Expand Down
2 changes: 2 additions & 0 deletions ModelContextProtocol.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,14 @@
<File Path="src/Directory.Build.props" />
<Project Path="src/ModelContextProtocol.Analyzers/ModelContextProtocol.Analyzers.csproj" />
<Project Path="src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj" />
<Project Path="src/ModelContextProtocol.AspNetCore.Distributed/ModelContextProtocol.AspNetCore.Distributed.csproj" />
<Project Path="src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj" />
<Project Path="src/ModelContextProtocol/ModelContextProtocol.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ModelContextProtocol.Analyzers.Tests/ModelContextProtocol.Analyzers.Tests.csproj" />
<Project Path="tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj" />
<Project Path="tests/ModelContextProtocol.AspNetCore.Distributed.Tests/ModelContextProtocol.AspNetCore.Distributed.Tests.csproj" />
<Project Path="tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj" />
<Project Path="tests/ModelContextProtocol.ConformanceClient/ModelContextProtocol.ConformanceClient.csproj" />
<Project Path="tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj" />
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@ 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)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Core.svg)](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)

- **[ModelContextProtocol](https://www.nuget.org/packages/ModelContextProtocol)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.svg)](https://www.nuget.org/packages/ModelContextProtocol) - The main package with hosting and dependency injection extensions. References `ModelContextProtocol.Core`. This is the right fit for most projects that don't need HTTP server capabilities. [Documentation](src/ModelContextProtocol/README.md)

- **[ModelContextProtocol.AspNetCore](https://www.nuget.org/packages/ModelContextProtocol.AspNetCore)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.AspNetCore.svg)](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)** [![NuGet preview version](https://img.shields.io/nuget/vpre/ModelContextProtocol.AspNetCore.Distributed.svg)](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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Resolves the listening endpoint address for the local server instance
/// that should be advertised to other instances for session affinity routing.
/// </summary>
public interface IListeningEndpointResolver
{
/// <summary>
/// Resolves the local server address that should be advertised to other instances
/// for session affinity routing.
/// </summary>
/// <param name="server">The server instance to resolve addresses from.</param>
/// <param name="options">Configuration options containing explicit address overrides.</param>
/// <returns>A normalized address string in the format "scheme://host:port".</returns>
/// <remarks>
/// The resolution strategy is:
/// <list type="number">
/// <item><description>If <see cref="SessionAffinityOptions.LocalServerAddress"/> is set, validate and return it</description></item>
/// <item><description>Otherwise, resolve from server bindings, preferring non-localhost HTTP addresses</description></item>
/// <item><description>Fall back to http://localhost:80 if no addresses are available</description></item>
/// </list>
/// </remarks>
string ResolveListeningEndpoint(IServer server, SessionAffinityOptions options);
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// A builder for configuring MCP session affinity.
/// </summary>
public interface ISessionAffinityBuilder
{
/// <summary>
/// Gets the host application builder.
/// </summary>
IServiceCollection Services { get; }
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Provides persistence for MCP session ownership.
/// </summary>
public interface ISessionStore
{
/// <summary>
/// Gets the current owner of a session, or claims ownership if unclaimed.
/// </summary>
/// <param name="sessionId">The session identifier.</param>
/// <param name="ownerInfoFactory">A factory function that creates the owner information if the session is unclaimed.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The current or newly claimed owner information for the session.</returns>
Task<SessionOwnerInfo> GetOrClaimOwnershipAsync(
string sessionId,
Func<CancellationToken, Task<SessionOwnerInfo>> ownerInfoFactory,
CancellationToken cancellationToken = default
);

/// <summary>
/// Removes a session from the store.
/// </summary>
/// <param name="sessionId">The session identifier to remove.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task RemoveAsync(string sessionId, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Configuration options for MCP session affinity routing behavior.
/// </summary>
public sealed class SessionAffinityOptions
{
/// <summary>
/// Configuration for the YARP forwarder when routing requests to other silos.
/// If not set, a default configuration will be used.
/// </summary>
public ForwarderRequestConfig? ForwarderRequestConfig { get; set; }

/// <summary>
/// Configuration for the HTTP client used when forwarding requests to other silos.
/// If not set, an empty configuration will be used.
/// </summary>
public HttpClientConfig? HttpClientConfig { get; set; }

/// <summary>
/// The service key to use when resolving the <see cref="Microsoft.Extensions.Caching.Hybrid.HybridCache"/> 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.
/// </summary>
/// <remarks>
/// This property is used in conjunction with keyed HybridCache registration.
/// Register a keyed HybridCache instance using the standard DI keyed services APIs.
/// </remarks>
public string? HybridCacheServiceKey { get; set; }

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para>
/// When set, this value takes precedence over automatic address resolution from server bindings.
/// This is useful in scenarios where:
/// <list type="bullet">
/// <item><description>Running in containerized environments where internal addresses differ from advertised addresses</description></item>
/// <item><description>Using service meshes where specific addresses/ports must be used for routing</description></item>
/// <item><description>Multiple network interfaces are available and a specific one should be used</description></item>
/// <item><description>Running behind load balancers or proxies with address translation</description></item>
/// </list>
/// </para>
/// <para>
/// The value must be a valid absolute URI including scheme (http or https), host, and port.
/// Examples:
/// <list type="bullet">
/// <item><description><c>http://pod-1.mcp-service.default.svc.cluster.local:8080</c></description></item>
/// <item><description><c>http://10.0.1.5:5000</c></description></item>
/// <item><description><c>https://server1.internal:443</c></description></item>
/// </list>
/// </para>
/// <para>
/// If not set, the address will be automatically resolved from the server's configured
/// bindings, preferring HTTP over HTTPS for service mesh scenarios.
/// </para>
/// </remarks>
[HttpOrHttpsUri]
public string? LocalServerAddress { get; set; }
}

/// <summary>
/// Validates that a string is a valid HTTP or HTTPS URI.
/// </summary>
[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;
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Validator for <see cref="SessionAffinityOptions"/> 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.
/// </summary>
[OptionsValidator]
internal sealed partial class SessionAffinityOptionsValidator
: IValidateOptions<SessionAffinityOptions>
{ }
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Identifies which server currently owns a session.
/// </summary>
public sealed record SessionOwnerInfo
{
/// <summary>Unique identifier for the owner (server id, instance id, etc.).</summary>
public required string OwnerId { get; init; }

/// <summary>Address (host[:port]) requests should be forwarded to.</summary>
public required string Address { get; init; }

/// <summary>Timestamp showing when the owner claimed this session.</summary>
public DateTimeOffset? ClaimedAt { get; init; }
}
Loading