From 47d84a4e6f9af9d0d1f3fbcc79bb7cffeb2d97bb Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 22 Jun 2026 13:49:58 -0400 Subject: [PATCH 1/9] Add dynamic MCP server sessions --- src/Repl.Mcp/McpReplExtensions.cs | 38 ++++++++++++++++++++++++++ src/Repl.Mcp/McpServerHandler.cs | 10 ++++++- src/Repl.Mcp/ReplMcpServerSession.cs | 41 ++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 src/Repl.Mcp/ReplMcpServerSession.cs diff --git a/src/Repl.Mcp/McpReplExtensions.cs b/src/Repl.Mcp/McpReplExtensions.cs index e05a31c..44ac537 100644 --- a/src/Repl.Mcp/McpReplExtensions.cs +++ b/src/Repl.Mcp/McpReplExtensions.cs @@ -64,6 +64,22 @@ public static McpServerOptions BuildMcpServerOptions( return app.Core.BuildMcpServerOptions(configure, app.Services); } + /// + /// Creates a session-aware MCP server configuration from a 's command graph. + /// Use this for multi-session transports such as Streamable HTTP. + /// + /// The Repl app. + /// Optional MCP configuration callback. + /// A disposable MCP server session. + public static ReplMcpServerSession CreateMcpServerSession( + this ReplApp app, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(app); + + return app.Core.CreateMcpServerSession(configure, app.Services); + } + /// /// Builds from the Repl app's command graph. /// Use this to integrate with custom transports (WebSocket, HTTP) or ASP.NET Core @@ -89,6 +105,28 @@ public static McpServerOptions BuildMcpServerOptions( return handler.BuildStaticServerOptions(); } + /// + /// Creates a session-aware MCP server configuration from a Repl command graph. + /// Use this for multi-session transports such as Streamable HTTP. + /// + /// The core Repl app. + /// Optional MCP configuration callback. + /// Optional service provider for DI during tool dispatch. + /// A disposable MCP server session. + public static ReplMcpServerSession CreateMcpServerSession( + this ICoreReplApp app, + Action? configure = null, + IServiceProvider? services = null) + { + ArgumentNullException.ThrowIfNull(app); + + var options = new ReplMcpServerOptions(); + configure?.Invoke(options); + + var handler = new McpServerHandler(app, options, services ?? EmptyServiceProvider.Instance); + return new ReplMcpServerSession(handler); + } + private sealed class EmptyServiceProvider : IServiceProvider { public static readonly EmptyServiceProvider Instance = new(); diff --git a/src/Repl.Mcp/McpServerHandler.cs b/src/Repl.Mcp/McpServerHandler.cs index aff21ab..cb9c45c 100644 --- a/src/Repl.Mcp/McpServerHandler.cs +++ b/src/Repl.Mcp/McpServerHandler.cs @@ -12,7 +12,7 @@ namespace Repl.Mcp; /// Orchestrates the MCP server lifecycle: builds the documentation model, /// generates MCP primitives, and runs the server until cancellation. /// -internal sealed class McpServerHandler +internal sealed class McpServerHandler : IDisposable { private const string DiscoverToolsName = "discover_tools"; private const string CallToolName = "call_tool"; @@ -69,6 +69,8 @@ public McpServerHandler( }); } + internal IServiceProvider SessionServices => _sessionServices; + [UnconditionalSuppressMessage( "Trimming", "IL2026", @@ -535,6 +537,12 @@ private void UnsubscribeFromRoutingChanges() } } + public void Dispose() + { + UnsubscribeFromRoutingChanges(); + _snapshotGate.Dispose(); + } + private ServerCapabilities BuildCapabilities() { var capabilities = new ServerCapabilities diff --git a/src/Repl.Mcp/ReplMcpServerSession.cs b/src/Repl.Mcp/ReplMcpServerSession.cs new file mode 100644 index 0000000..f31df45 --- /dev/null +++ b/src/Repl.Mcp/ReplMcpServerSession.cs @@ -0,0 +1,41 @@ +using ModelContextProtocol.Server; + +namespace Repl.Mcp; + +/// +/// Represents a dynamically generated MCP server session for custom transports. +/// +public sealed class ReplMcpServerSession : IDisposable +{ + private readonly McpServerHandler _handler; + private bool _disposed; + + internal ReplMcpServerSession(McpServerHandler handler) + { + _handler = handler; + ServerOptions = handler.BuildDynamicServerOptions(); + Services = handler.SessionServices; + } + + /// + /// Gets the MCP server options for this session. + /// + public McpServerOptions ServerOptions { get; } + + /// + /// Gets the session-aware service provider for this session. + /// + public IServiceProvider Services { get; } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _handler.Dispose(); + _disposed = true; + } +} From 1c4bbaf95cc07e6ac1ab58f8184f3192bc72f93f Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 22 Jun 2026 13:51:45 -0400 Subject: [PATCH 2/9] Add ASP.NET Core MCP package shell --- src/Directory.Packages.props | 3 ++- src/Repl.Mcp.AspNetCore/README.md | 13 +++++++++++ .../Repl.Mcp.AspNetCore.csproj | 22 +++++++++++++++++++ src/Repl.slnx | 1 + 4 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 src/Repl.Mcp.AspNetCore/README.md create mode 100644 src/Repl.Mcp.AspNetCore/Repl.Mcp.AspNetCore.csproj diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 8a9783c..eb0bfc6 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -14,7 +14,8 @@ - + + diff --git a/src/Repl.Mcp.AspNetCore/README.md b/src/Repl.Mcp.AspNetCore/README.md new file mode 100644 index 0000000..5a81bea --- /dev/null +++ b/src/Repl.Mcp.AspNetCore/README.md @@ -0,0 +1,13 @@ +# Repl.Mcp.AspNetCore + +ASP.NET Core Streamable HTTP hosting integration for Repl MCP servers. + +```csharp +builder.Services.AddReplMcpHttp(replApp); + +var app = builder.Build(); +app.MapReplMcp("/mcp"); +``` + +Use ASP.NET Core middleware and endpoint conventions for production concerns +such as authentication, authorization, CORS, HTTPS, and reverse proxy hosting. diff --git a/src/Repl.Mcp.AspNetCore/Repl.Mcp.AspNetCore.csproj b/src/Repl.Mcp.AspNetCore/Repl.Mcp.AspNetCore.csproj new file mode 100644 index 0000000..b7e23d3 --- /dev/null +++ b/src/Repl.Mcp.AspNetCore/Repl.Mcp.AspNetCore.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + ASP.NET Core Streamable HTTP hosting integration for Repl MCP servers. + README.md + + + + + + + + + + + + + + + + diff --git a/src/Repl.slnx b/src/Repl.slnx index e4c2ae3..c1b55f3 100644 --- a/src/Repl.slnx +++ b/src/Repl.slnx @@ -15,6 +15,7 @@ + From 4a6ab40e6595516dcadbc21f492aa02a945ed7d2 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 22 Jun 2026 13:58:35 -0400 Subject: [PATCH 3/9] Add hosted MCP HTTP extensions --- .../CompositeServiceProvider.cs | 9 ++ .../ReplMcpEndpointRouteBuilderExtensions.cs | 24 ++++++ src/Repl.Mcp.AspNetCore/ReplMcpHttpOptions.cs | 45 ++++++++++ .../ReplMcpHttpServiceCollectionExtensions.cs | 86 +++++++++++++++++++ src/Repl.Mcp/McpReplExtensions.cs | 19 ++++ 5 files changed, 183 insertions(+) create mode 100644 src/Repl.Mcp.AspNetCore/CompositeServiceProvider.cs create mode 100644 src/Repl.Mcp.AspNetCore/ReplMcpEndpointRouteBuilderExtensions.cs create mode 100644 src/Repl.Mcp.AspNetCore/ReplMcpHttpOptions.cs create mode 100644 src/Repl.Mcp.AspNetCore/ReplMcpHttpServiceCollectionExtensions.cs diff --git a/src/Repl.Mcp.AspNetCore/CompositeServiceProvider.cs b/src/Repl.Mcp.AspNetCore/CompositeServiceProvider.cs new file mode 100644 index 0000000..9ec3f90 --- /dev/null +++ b/src/Repl.Mcp.AspNetCore/CompositeServiceProvider.cs @@ -0,0 +1,9 @@ +namespace Repl.Mcp.AspNetCore; + +internal sealed class CompositeServiceProvider( + IServiceProvider primary, + IServiceProvider fallback) : IServiceProvider +{ + public object? GetService(Type serviceType) => + primary.GetService(serviceType) ?? fallback.GetService(serviceType); +} diff --git a/src/Repl.Mcp.AspNetCore/ReplMcpEndpointRouteBuilderExtensions.cs b/src/Repl.Mcp.AspNetCore/ReplMcpEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..f31a921 --- /dev/null +++ b/src/Repl.Mcp.AspNetCore/ReplMcpEndpointRouteBuilderExtensions.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; + +namespace Repl.Mcp.AspNetCore; + +/// +/// Extension methods for mapping Repl MCP endpoints. +/// +public static class ReplMcpEndpointRouteBuilderExtensions +{ + /// + /// Maps the Repl MCP Streamable HTTP endpoint. + /// + /// Endpoint route builder. + /// Route pattern for the Streamable HTTP endpoint. + /// An endpoint convention builder. + public static IEndpointConventionBuilder MapReplMcp( + this IEndpointRouteBuilder endpoints, + string pattern = "/mcp") + { + ArgumentNullException.ThrowIfNull(endpoints); + return endpoints.MapMcp(pattern); + } +} diff --git a/src/Repl.Mcp.AspNetCore/ReplMcpHttpOptions.cs b/src/Repl.Mcp.AspNetCore/ReplMcpHttpOptions.cs new file mode 100644 index 0000000..f0ee30a --- /dev/null +++ b/src/Repl.Mcp.AspNetCore/ReplMcpHttpOptions.cs @@ -0,0 +1,45 @@ +using ModelContextProtocol.AspNetCore; +using Repl.Mcp; + +namespace Repl.Mcp.AspNetCore; + +/// +/// Configures Repl MCP over ASP.NET Core Streamable HTTP. +/// +public sealed class ReplMcpHttpOptions +{ + /// + /// Gets or sets a callback for configuring the Repl MCP server surface created for each MCP session. + /// + public Action? ConfigureServer { get; set; } + + /// + /// Gets or sets a callback for configuring the underlying MCP HTTP transport. + /// + public Action? ConfigureTransport { get; set; } + + /// + /// Gets or sets a value indicating whether MCP authorization filters should be registered. + /// + public bool EnableAuthorizationFilters { get; set; } + + /// + /// Gets or sets a value indicating whether HTTP sessions should be stateless. + /// + public bool Stateless { get; set; } + + /// + /// Gets or sets a value indicating whether a single execution context should be used per MCP session. + /// + public bool PerSessionExecutionContext { get; set; } + + /// + /// Gets or sets the amount of idle time after which a stateful MCP session expires. + /// + public TimeSpan? IdleTimeout { get; set; } + + /// + /// Gets or sets the maximum number of idle stateful MCP sessions to keep in memory. + /// + public int? MaxIdleSessionCount { get; set; } +} diff --git a/src/Repl.Mcp.AspNetCore/ReplMcpHttpServiceCollectionExtensions.cs b/src/Repl.Mcp.AspNetCore/ReplMcpHttpServiceCollectionExtensions.cs new file mode 100644 index 0000000..059a342 --- /dev/null +++ b/src/Repl.Mcp.AspNetCore/ReplMcpHttpServiceCollectionExtensions.cs @@ -0,0 +1,86 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using ModelContextProtocol.AspNetCore; +using ModelContextProtocol.Server; +using Repl.Mcp; + +namespace Repl.Mcp.AspNetCore; + +/// +/// Extension methods for registering Repl MCP over ASP.NET Core Streamable HTTP. +/// +public static class ReplMcpHttpServiceCollectionExtensions +{ + /// + /// Registers Repl MCP server services using the MCP Streamable HTTP transport. + /// + /// Service collection. + /// The Repl app to expose over MCP. + /// Optional HTTP MCP configuration callback. + /// The MCP server builder. + public static IMcpServerBuilder AddReplMcpHttp( + this IServiceCollection services, + ReplApp app, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(app); + + var options = new ReplMcpHttpOptions(); + configure?.Invoke(options); + + services.TryAddSingleton(app); + + var builder = services.AddMcpServer(); + if (options.EnableAuthorizationFilters) + { + builder.AddAuthorizationFilters(); + } + + builder.WithHttpTransport(http => + { + ApplyTransportOptions(http, options); + options.ConfigureTransport?.Invoke(http); + + var configureSessionOptions = http.ConfigureSessionOptions; + http.ConfigureSessionOptions = async (context, serverOptions, cancellationToken) => + { + var sessionServices = new CompositeServiceProvider(context.RequestServices, app.Services); + var session = app.CreateMcpServerSession(sessionServices, options.ConfigureServer); + CopyServerOptions(session.ServerOptions, serverOptions); + + if (configureSessionOptions is not null) + { + await configureSessionOptions(context, serverOptions, cancellationToken).ConfigureAwait(false); + } + }; + }); + + return builder; + } + + private static void ApplyTransportOptions( + HttpServerTransportOptions transport, + ReplMcpHttpOptions options) + { + transport.Stateless = options.Stateless; + transport.PerSessionExecutionContext = options.PerSessionExecutionContext; + + if (options.IdleTimeout is { } idleTimeout) + { + transport.IdleTimeout = idleTimeout; + } + + if (options.MaxIdleSessionCount is { } maxIdleSessionCount) + { + transport.MaxIdleSessionCount = maxIdleSessionCount; + } + } + + private static void CopyServerOptions(McpServerOptions source, McpServerOptions target) + { + target.ServerInfo = source.ServerInfo; + target.Capabilities = source.Capabilities; + target.Handlers = source.Handlers; + } +} diff --git a/src/Repl.Mcp/McpReplExtensions.cs b/src/Repl.Mcp/McpReplExtensions.cs index 44ac537..2c0a479 100644 --- a/src/Repl.Mcp/McpReplExtensions.cs +++ b/src/Repl.Mcp/McpReplExtensions.cs @@ -80,6 +80,25 @@ public static ReplMcpServerSession CreateMcpServerSession( return app.Core.CreateMcpServerSession(configure, app.Services); } + /// + /// Creates a session-aware MCP server configuration from a 's command graph + /// using an externally supplied service provider during tool dispatch. + /// + /// The Repl app. + /// Service provider used for DI during tool dispatch. + /// Optional MCP configuration callback. + /// A disposable MCP server session. + public static ReplMcpServerSession CreateMcpServerSession( + this ReplApp app, + IServiceProvider services, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(app); + ArgumentNullException.ThrowIfNull(services); + + return app.Core.CreateMcpServerSession(configure, services); + } + /// /// Builds from the Repl app's command graph. /// Use this to integrate with custom transports (WebSocket, HTTP) or ASP.NET Core From df7e8167ec265fa49240d51932d6080d24330bcc Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 22 Jun 2026 14:08:29 -0400 Subject: [PATCH 4/9] Add MCP HTTP self-host command --- src/Repl.Mcp.AspNetCore/McpHttpBinding.cs | 9 ++ .../McpHttpBindingFactory.cs | 89 +++++++++++++ src/Repl.Mcp.AspNetCore/McpHttpModule.cs | 125 ++++++++++++++++++ .../Repl.Mcp.AspNetCore.csproj | 4 + .../ReplMcpHttpReplAppExtensions.cs | 29 ++++ src/Repl.Mcp.AspNetCore/ReplMcpHttpServer.cs | 89 +++++++++++++ .../ReplMcpHttpServerOptions.cs | 112 ++++++++++++++++ src/Repl.McpTests/Given_McpHttpBinding.cs | 64 +++++++++ src/Repl.McpTests/Repl.McpTests.csproj | 1 + 9 files changed, 522 insertions(+) create mode 100644 src/Repl.Mcp.AspNetCore/McpHttpBinding.cs create mode 100644 src/Repl.Mcp.AspNetCore/McpHttpBindingFactory.cs create mode 100644 src/Repl.Mcp.AspNetCore/McpHttpModule.cs create mode 100644 src/Repl.Mcp.AspNetCore/ReplMcpHttpReplAppExtensions.cs create mode 100644 src/Repl.Mcp.AspNetCore/ReplMcpHttpServer.cs create mode 100644 src/Repl.Mcp.AspNetCore/ReplMcpHttpServerOptions.cs create mode 100644 src/Repl.McpTests/Given_McpHttpBinding.cs diff --git a/src/Repl.Mcp.AspNetCore/McpHttpBinding.cs b/src/Repl.Mcp.AspNetCore/McpHttpBinding.cs new file mode 100644 index 0000000..47aa53f --- /dev/null +++ b/src/Repl.Mcp.AspNetCore/McpHttpBinding.cs @@ -0,0 +1,9 @@ +namespace Repl.Mcp.AspNetCore; + +internal sealed record McpHttpBinding( + string Host, + int Port, + string Path, + string ListenUrl, + string EndpointUrl, + bool AllowsRemote); diff --git a/src/Repl.Mcp.AspNetCore/McpHttpBindingFactory.cs b/src/Repl.Mcp.AspNetCore/McpHttpBindingFactory.cs new file mode 100644 index 0000000..89303da --- /dev/null +++ b/src/Repl.Mcp.AspNetCore/McpHttpBindingFactory.cs @@ -0,0 +1,89 @@ +using System.Globalization; +using System.Net; +using System.Net.Sockets; + +namespace Repl.Mcp.AspNetCore; + +internal static class McpHttpBindingFactory +{ + public static McpHttpBinding Create( + string? host, + int port, + string? path, + bool allowRemote) + { + if (port is < 1 or > 65535) + { + throw new InvalidOperationException("MCP HTTP port must be between 1 and 65535."); + } + + var effectiveHost = string.IsNullOrWhiteSpace(host) + ? ReplMcpHttpServerOptions.DefaultHost + : host.Trim(); + + var isLoopback = IsLoopbackHost(effectiveHost); + if (!allowRemote && !isLoopback) + { + throw new InvalidOperationException( + "Remote MCP HTTP bindings require --allow-remote. Use 127.0.0.1 or localhost for local-only serving."); + } + + var normalizedPath = NormalizePath(path); + var urlHost = FormatUrlHost(effectiveHost); + var listenUrl = string.Concat("http://", urlHost, ":", port.ToString(CultureInfo.InvariantCulture)); + var endpointUrl = string.Concat(listenUrl, normalizedPath); + + return new McpHttpBinding( + effectiveHost, + port, + normalizedPath, + listenUrl, + endpointUrl, + !isLoopback); + } + + private static string NormalizePath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return ReplMcpHttpServerOptions.DefaultPath; + } + + var normalized = path.Trim(); + if (normalized[0] != '/') + { + normalized = "/" + normalized; + } + + return normalized.Length == 0 ? ReplMcpHttpServerOptions.DefaultPath : normalized; + } + + private static bool IsLoopbackHost(string host) + { + var normalized = TrimIpv6Brackets(host); + if (string.Equals(normalized, "localhost", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return IPAddress.TryParse(normalized, out var address) + && IPAddress.IsLoopback(address); + } + + private static string FormatUrlHost(string host) + { + var normalized = TrimIpv6Brackets(host); + if (IPAddress.TryParse(normalized, out var address) + && address.AddressFamily == AddressFamily.InterNetworkV6) + { + return $"[{normalized}]"; + } + + return normalized; + } + + private static string TrimIpv6Brackets(string host) => + host.Length > 1 && host[0] == '[' && host[^1] == ']' + ? host[1..^1] + : host; +} diff --git a/src/Repl.Mcp.AspNetCore/McpHttpModule.cs b/src/Repl.Mcp.AspNetCore/McpHttpModule.cs new file mode 100644 index 0000000..2889b21 --- /dev/null +++ b/src/Repl.Mcp.AspNetCore/McpHttpModule.cs @@ -0,0 +1,125 @@ +using Repl.Mcp; + +namespace Repl.Mcp.AspNetCore; + +internal sealed class McpHttpModule : IReplModule +{ + private readonly ReplApp _app; + private readonly ReplMcpHttpServerOptions _options; + private readonly ReplMcpServerOptions _mcpOptions; + + public McpHttpModule(ReplApp app, ReplMcpHttpServerOptions options) + { + _app = app; + _options = options; + _mcpOptions = new ReplMcpServerOptions(); + options.ConfigureServer?.Invoke(_mcpOptions); + } + + public void Map(IReplMap map) + { + map.Context(_mcpOptions.ContextName, mcp => + { + mcp.Map("httpserve", + HandleHttpServeAsync) + .WithDescription("Start MCP Streamable HTTP server for agent integration.") + .WithAlias("http", "http-serve") + .Hidden(); + }) + .Hidden(); + } + + private async Task HandleHttpServeAsync( + IReplIoContext io, + string? host, + int? port, + string? path, + bool allowRemote, + bool stateless, + int? idleTimeoutSeconds, + int? maxIdleSessions, + bool quiet, + CancellationToken cancellationToken) + { + var runOptions = CreateRunOptions( + host, + port, + path, + allowRemote, + stateless, + idleTimeoutSeconds, + maxIdleSessions, + quiet); + + try + { + await ReplMcpHttpServer.RunAsync( + _app, + runOptions, + io.Output, + cancellationToken).ConfigureAwait(false); + return Results.Exit(0); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return Results.Exit(0); + } + catch (Exception ex) + { + await io.Output.WriteLineAsync($"Error: {ex.Message}").ConfigureAwait(false); + return Results.Exit(1); + } + } + + private ReplMcpHttpServerOptions CreateRunOptions( + string? host, + int? port, + string? path, + bool allowRemote, + bool stateless, + int? idleTimeoutSeconds, + int? maxIdleSessions, + bool quiet) + { + var runOptions = _options.Clone(); + ApplyEndpointOptions(runOptions, host, port, path); + + runOptions.AllowRemote |= allowRemote; + runOptions.Stateless |= stateless; + runOptions.Quiet |= quiet; + + if (idleTimeoutSeconds is { } idleSeconds) + { + runOptions.IdleTimeout = TimeSpan.FromSeconds(idleSeconds); + } + + if (maxIdleSessions is { } maxSessions) + { + runOptions.MaxIdleSessionCount = maxSessions; + } + + return runOptions; + } + + private static void ApplyEndpointOptions( + ReplMcpHttpServerOptions runOptions, + string? host, + int? port, + string? path) + { + if (!string.IsNullOrWhiteSpace(host)) + { + runOptions.Host = host; + } + + if (port is { } portValue) + { + runOptions.Port = portValue; + } + + if (!string.IsNullOrWhiteSpace(path)) + { + runOptions.Path = path; + } + } +} diff --git a/src/Repl.Mcp.AspNetCore/Repl.Mcp.AspNetCore.csproj b/src/Repl.Mcp.AspNetCore/Repl.Mcp.AspNetCore.csproj index b7e23d3..35a7899 100644 --- a/src/Repl.Mcp.AspNetCore/Repl.Mcp.AspNetCore.csproj +++ b/src/Repl.Mcp.AspNetCore/Repl.Mcp.AspNetCore.csproj @@ -6,6 +6,10 @@ README.md + + + + diff --git a/src/Repl.Mcp.AspNetCore/ReplMcpHttpReplAppExtensions.cs b/src/Repl.Mcp.AspNetCore/ReplMcpHttpReplAppExtensions.cs new file mode 100644 index 0000000..d59a252 --- /dev/null +++ b/src/Repl.Mcp.AspNetCore/ReplMcpHttpReplAppExtensions.cs @@ -0,0 +1,29 @@ +namespace Repl.Mcp.AspNetCore; + +/// +/// Extension methods for enabling Repl MCP Streamable HTTP CLI hosting. +/// +public static class ReplMcpHttpReplAppExtensions +{ + /// + /// Enables MCP Streamable HTTP server mode via {commandName} mcp httpserve. + /// + /// Target Repl app. + /// Optional server configuration callback. + /// The same app instance. + public static ReplApp UseMcpHttpServer( + this ReplApp app, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(app); + + var options = new ReplMcpHttpServerOptions(); + configure?.Invoke(options); + + app.MapModule( + new McpHttpModule(app, options), + static context => context.Channel is ReplRuntimeChannel.Cli); + + return app; + } +} diff --git a/src/Repl.Mcp.AspNetCore/ReplMcpHttpServer.cs b/src/Repl.Mcp.AspNetCore/ReplMcpHttpServer.cs new file mode 100644 index 0000000..8313928 --- /dev/null +++ b/src/Repl.Mcp.AspNetCore/ReplMcpHttpServer.cs @@ -0,0 +1,89 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; + +namespace Repl.Mcp.AspNetCore; + +/// +/// Runs self-hosted Repl MCP Streamable HTTP servers. +/// +public static class ReplMcpHttpServer +{ + /// + /// Runs a self-hosted Repl MCP Streamable HTTP server until cancellation is requested. + /// + /// The Repl app to expose over MCP. + /// Optional server configuration callback. + /// Optional startup output writer. + /// Cancellation token. + public static async Task RunAsync( + ReplApp app, + Action? configure = null, + TextWriter? output = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(app); + + var options = new ReplMcpHttpServerOptions(); + configure?.Invoke(options); + + await RunAsync(app, options, output, cancellationToken).ConfigureAwait(false); + } + + internal static async Task RunAsync( + ReplApp replApp, + ReplMcpHttpServerOptions options, + TextWriter? output, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(replApp); + ArgumentNullException.ThrowIfNull(options); + + var binding = McpHttpBindingFactory.Create( + options.Host, + options.Port, + options.Path, + options.AllowRemote); + + var builder = WebApplication.CreateSlimBuilder(); + builder.Logging.ClearProviders(); + builder.WebHost.UseUrls(binding.ListenUrl); + builder.Services.AddReplMcpHttp(replApp, http => + { + var httpOptions = options.ToHttpOptions(); + http.ConfigureServer = httpOptions.ConfigureServer; + http.ConfigureTransport = httpOptions.ConfigureTransport; + http.EnableAuthorizationFilters = httpOptions.EnableAuthorizationFilters; + http.Stateless = httpOptions.Stateless; + http.PerSessionExecutionContext = httpOptions.PerSessionExecutionContext; + http.IdleTimeout = httpOptions.IdleTimeout; + http.MaxIdleSessionCount = httpOptions.MaxIdleSessionCount; + }); + + var webApp = builder.Build(); + await using (webApp.ConfigureAwait(false)) + { + webApp.MapReplMcp(binding.Path); + + if (!options.Quiet && output is not null) + { + await output.WriteLineAsync($"MCP HTTP server listening on {binding.EndpointUrl}").ConfigureAwait(false); + await output.WriteLineAsync( + binding.AllowsRemote + ? "Remote clients are allowed by the selected binding." + : "Remote clients are disabled; bind a non-loopback host with --allow-remote to expose it.") + .ConfigureAwait(false); + } + + await webApp.StartAsync(cancellationToken).ConfigureAwait(false); + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, TimeProvider.System, cancellationToken).ConfigureAwait(false); + } + finally + { + await webApp.StopAsync(CancellationToken.None).ConfigureAwait(false); + } + } + } +} diff --git a/src/Repl.Mcp.AspNetCore/ReplMcpHttpServerOptions.cs b/src/Repl.Mcp.AspNetCore/ReplMcpHttpServerOptions.cs new file mode 100644 index 0000000..23f981c --- /dev/null +++ b/src/Repl.Mcp.AspNetCore/ReplMcpHttpServerOptions.cs @@ -0,0 +1,112 @@ +using ModelContextProtocol.AspNetCore; +using Repl.Mcp; + +namespace Repl.Mcp.AspNetCore; + +/// +/// Configures the self-hosted Repl MCP Streamable HTTP server. +/// +public sealed class ReplMcpHttpServerOptions +{ + /// + /// Default local-only host. + /// + public const string DefaultHost = "127.0.0.1"; + + /// + /// Default HTTP port. The digits correspond to "repl" on a phone keypad. + /// + public const int DefaultPort = 7375; + + /// + /// Default Streamable HTTP endpoint path. + /// + public const string DefaultPath = "/mcp"; + + /// + /// Gets or sets the hostname or IP address to bind. + /// + public string Host { get; set; } = DefaultHost; + + /// + /// Gets or sets the HTTP port to bind. + /// + public int Port { get; set; } = DefaultPort; + + /// + /// Gets or sets the Streamable HTTP endpoint path. + /// + public string Path { get; set; } = DefaultPath; + + /// + /// Gets or sets a value indicating whether non-loopback bindings are allowed. + /// + public bool AllowRemote { get; set; } + + /// + /// Gets or sets a value indicating whether startup messages should be suppressed. + /// + public bool Quiet { get; set; } + + /// + /// Gets or sets a callback for configuring the Repl MCP server surface created for each MCP session. + /// + public Action? ConfigureServer { get; set; } + + /// + /// Gets or sets a callback for configuring the underlying MCP HTTP transport. + /// + public Action? ConfigureTransport { get; set; } + + /// + /// Gets or sets a value indicating whether MCP authorization filters should be registered. + /// + public bool EnableAuthorizationFilters { get; set; } + + /// + /// Gets or sets a value indicating whether HTTP sessions should be stateless. + /// + public bool Stateless { get; set; } + + /// + /// Gets or sets a value indicating whether a single execution context should be used per MCP session. + /// + public bool PerSessionExecutionContext { get; set; } + + /// + /// Gets or sets the amount of idle time after which a stateful MCP session expires. + /// + public TimeSpan? IdleTimeout { get; set; } + + /// + /// Gets or sets the maximum number of idle stateful MCP sessions to keep in memory. + /// + public int? MaxIdleSessionCount { get; set; } + + internal ReplMcpHttpOptions ToHttpOptions() => new() + { + ConfigureServer = ConfigureServer, + ConfigureTransport = ConfigureTransport, + EnableAuthorizationFilters = EnableAuthorizationFilters, + Stateless = Stateless, + PerSessionExecutionContext = PerSessionExecutionContext, + IdleTimeout = IdleTimeout, + MaxIdleSessionCount = MaxIdleSessionCount, + }; + + internal ReplMcpHttpServerOptions Clone() => new() + { + Host = Host, + Port = Port, + Path = Path, + AllowRemote = AllowRemote, + Quiet = Quiet, + ConfigureServer = ConfigureServer, + ConfigureTransport = ConfigureTransport, + EnableAuthorizationFilters = EnableAuthorizationFilters, + Stateless = Stateless, + PerSessionExecutionContext = PerSessionExecutionContext, + IdleTimeout = IdleTimeout, + MaxIdleSessionCount = MaxIdleSessionCount, + }; +} diff --git a/src/Repl.McpTests/Given_McpHttpBinding.cs b/src/Repl.McpTests/Given_McpHttpBinding.cs new file mode 100644 index 0000000..5ab25a3 --- /dev/null +++ b/src/Repl.McpTests/Given_McpHttpBinding.cs @@ -0,0 +1,64 @@ +using Repl.Mcp.AspNetCore; + +namespace Repl.McpTests; + +[TestClass] +public sealed class Given_McpHttpBinding +{ + [TestMethod] + public void When_DefaultBindingCreated_Then_UsesLocalReplPort() + { + var binding = McpHttpBindingFactory.Create( + host: null, + ReplMcpHttpServerOptions.DefaultPort, + path: null, + allowRemote: false); + + binding.Host.Should().Be("127.0.0.1"); + binding.Port.Should().Be(7375); + binding.Path.Should().Be("/mcp"); + binding.EndpointUrl.Should().Be("http://127.0.0.1:7375/mcp"); + binding.AllowsRemote.Should().BeFalse(); + } + + [TestMethod] + public void When_RemoteBindingWithoutOptIn_Then_Fails() + { + var action = () => McpHttpBindingFactory.Create( + "0.0.0.0", + ReplMcpHttpServerOptions.DefaultPort, + "/mcp", + allowRemote: false); + + action.Should().Throw() + .WithMessage("*--allow-remote*"); + } + + [TestMethod] + public void When_RemoteBindingAllowed_Then_UsesRequestedHost() + { + var binding = McpHttpBindingFactory.Create( + "0.0.0.0", + 4342, + "mcp", + allowRemote: true); + + binding.ListenUrl.Should().Be("http://0.0.0.0:4342"); + binding.EndpointUrl.Should().Be("http://0.0.0.0:4342/mcp"); + binding.AllowsRemote.Should().BeTrue(); + } + + [TestMethod] + public void When_Ipv6LoopbackBindingCreated_Then_HostIsBracketedInUrl() + { + var binding = McpHttpBindingFactory.Create( + "::1", + 7375, + "/mcp", + allowRemote: false); + + binding.ListenUrl.Should().Be("http://[::1]:7375"); + binding.EndpointUrl.Should().Be("http://[::1]:7375/mcp"); + binding.AllowsRemote.Should().BeFalse(); + } +} diff --git a/src/Repl.McpTests/Repl.McpTests.csproj b/src/Repl.McpTests/Repl.McpTests.csproj index 077532d..43d5e21 100644 --- a/src/Repl.McpTests/Repl.McpTests.csproj +++ b/src/Repl.McpTests/Repl.McpTests.csproj @@ -13,6 +13,7 @@ + From d387c3aa9a91e9ce937f50553467beea4f98e308 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 22 Jun 2026 14:09:11 -0400 Subject: [PATCH 5/9] Document MCP HTTP hosting --- src/Repl.Mcp.AspNetCore/README.md | 37 +++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/Repl.Mcp.AspNetCore/README.md b/src/Repl.Mcp.AspNetCore/README.md index 5a81bea..38791eb 100644 --- a/src/Repl.Mcp.AspNetCore/README.md +++ b/src/Repl.Mcp.AspNetCore/README.md @@ -2,6 +2,8 @@ ASP.NET Core Streamable HTTP hosting integration for Repl MCP servers. +## Hosted in ASP.NET Core + ```csharp builder.Services.AddReplMcpHttp(replApp); @@ -11,3 +13,38 @@ app.MapReplMcp("/mcp"); Use ASP.NET Core middleware and endpoint conventions for production concerns such as authentication, authorization, CORS, HTTPS, and reverse proxy hosting. + +```csharp +builder.Services.AddAuthentication(); +builder.Services.AddAuthorization(); +builder.Services.AddReplMcpHttp(replApp); + +var app = builder.Build(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapReplMcp("/mcp").RequireAuthorization(); +``` + +## Self-Hosted CLI + +Register the local HTTP command on a Repl app: + +```csharp +var replApp = ReplApp.Create() + .UseMcpHttpServer(); +``` + +Then run: + +```bash +myapp mcp httpserve +``` + +The default endpoint is `http://127.0.0.1:7375/mcp`. The port digits map to +`repl` on a phone keypad. Non-loopback bindings require an explicit opt-in: + +```bash +myapp mcp httpserve --host 0.0.0.0 --allow-remote +``` + +The `http` and `http-serve` aliases also resolve to `httpserve`. From 75c4fcc0010d0a0dbf914aae5cb5cd2a749dab37 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 22 Jun 2026 16:49:33 -0400 Subject: [PATCH 6/9] Harden MCP HTTP self-host defaults --- src/Repl.Mcp.AspNetCore/McpHttpModule.cs | 10 +- .../ReplMcpHttpDiagnostics.cs | 15 ++ src/Repl.Mcp.AspNetCore/ReplMcpHttpOptions.cs | 11 ++ .../ReplMcpHttpSecurityMiddleware.cs | 128 ++++++++++++++++++ .../ReplMcpHttpSecurityOptions.cs | 33 +++++ src/Repl.Mcp.AspNetCore/ReplMcpHttpServer.cs | 25 ++-- .../ReplMcpHttpServerOptions.cs | 111 ++++++++------- .../ReplMcpHttpServiceCollectionExtensions.cs | 21 ++- .../Given_McpHttpSelfHostSecurity.cs | 128 ++++++++++++++++++ 9 files changed, 411 insertions(+), 71 deletions(-) create mode 100644 src/Repl.Mcp.AspNetCore/ReplMcpHttpDiagnostics.cs create mode 100644 src/Repl.Mcp.AspNetCore/ReplMcpHttpSecurityMiddleware.cs create mode 100644 src/Repl.Mcp.AspNetCore/ReplMcpHttpSecurityOptions.cs create mode 100644 src/Repl.McpTests/Given_McpHttpSelfHostSecurity.cs diff --git a/src/Repl.Mcp.AspNetCore/McpHttpModule.cs b/src/Repl.Mcp.AspNetCore/McpHttpModule.cs index 2889b21..8a70256 100644 --- a/src/Repl.Mcp.AspNetCore/McpHttpModule.cs +++ b/src/Repl.Mcp.AspNetCore/McpHttpModule.cs @@ -13,7 +13,7 @@ public McpHttpModule(ReplApp app, ReplMcpHttpServerOptions options) _app = app; _options = options; _mcpOptions = new ReplMcpServerOptions(); - options.ConfigureServer?.Invoke(_mcpOptions); + options.Http.ConfigureServer?.Invoke(_mcpOptions); } public void Map(IReplMap map) @@ -35,7 +35,6 @@ private async Task HandleHttpServeAsync( int? port, string? path, bool allowRemote, - bool stateless, int? idleTimeoutSeconds, int? maxIdleSessions, bool quiet, @@ -46,7 +45,6 @@ private async Task HandleHttpServeAsync( port, path, allowRemote, - stateless, idleTimeoutSeconds, maxIdleSessions, quiet); @@ -76,7 +74,6 @@ private ReplMcpHttpServerOptions CreateRunOptions( int? port, string? path, bool allowRemote, - bool stateless, int? idleTimeoutSeconds, int? maxIdleSessions, bool quiet) @@ -85,17 +82,16 @@ private ReplMcpHttpServerOptions CreateRunOptions( ApplyEndpointOptions(runOptions, host, port, path); runOptions.AllowRemote |= allowRemote; - runOptions.Stateless |= stateless; runOptions.Quiet |= quiet; if (idleTimeoutSeconds is { } idleSeconds) { - runOptions.IdleTimeout = TimeSpan.FromSeconds(idleSeconds); + runOptions.Http.IdleTimeout = TimeSpan.FromSeconds(idleSeconds); } if (maxIdleSessions is { } maxSessions) { - runOptions.MaxIdleSessionCount = maxSessions; + runOptions.Http.MaxIdleSessionCount = maxSessions; } return runOptions; diff --git a/src/Repl.Mcp.AspNetCore/ReplMcpHttpDiagnostics.cs b/src/Repl.Mcp.AspNetCore/ReplMcpHttpDiagnostics.cs new file mode 100644 index 0000000..a549e4b --- /dev/null +++ b/src/Repl.Mcp.AspNetCore/ReplMcpHttpDiagnostics.cs @@ -0,0 +1,15 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace Repl.Mcp.AspNetCore; + +internal static class ReplMcpHttpDiagnostics +{ + public const string MeterName = "Repl.Mcp.Http"; + public const string ActivitySourceName = "Repl.Mcp.Http"; + + public static readonly Meter Meter = new(MeterName); + public static readonly ActivitySource ActivitySource = new(ActivitySourceName); + public static readonly Counter RejectedRequests = Meter.CreateCounter("repl.mcp.http.requests.rejected"); + public static readonly Counter StartupFailures = Meter.CreateCounter("repl.mcp.http.startup.failures"); +} diff --git a/src/Repl.Mcp.AspNetCore/ReplMcpHttpOptions.cs b/src/Repl.Mcp.AspNetCore/ReplMcpHttpOptions.cs index f0ee30a..3e2f4d9 100644 --- a/src/Repl.Mcp.AspNetCore/ReplMcpHttpOptions.cs +++ b/src/Repl.Mcp.AspNetCore/ReplMcpHttpOptions.cs @@ -42,4 +42,15 @@ public sealed class ReplMcpHttpOptions /// Gets or sets the maximum number of idle stateful MCP sessions to keep in memory. /// public int? MaxIdleSessionCount { get; set; } + + internal ReplMcpHttpOptions Clone() => new() + { + ConfigureServer = ConfigureServer, + ConfigureTransport = ConfigureTransport, + EnableAuthorizationFilters = EnableAuthorizationFilters, + Stateless = Stateless, + PerSessionExecutionContext = PerSessionExecutionContext, + IdleTimeout = IdleTimeout, + MaxIdleSessionCount = MaxIdleSessionCount, + }; } diff --git a/src/Repl.Mcp.AspNetCore/ReplMcpHttpSecurityMiddleware.cs b/src/Repl.Mcp.AspNetCore/ReplMcpHttpSecurityMiddleware.cs new file mode 100644 index 0000000..526e203 --- /dev/null +++ b/src/Repl.Mcp.AspNetCore/ReplMcpHttpSecurityMiddleware.cs @@ -0,0 +1,128 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Repl.Mcp.AspNetCore; + +internal sealed class ReplMcpHttpSecurityMiddleware +{ + private static readonly Action LogRejectedHost = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(1, nameof(LogRejectedHost)), + "Rejected MCP HTTP request with Host '{Host}'."); + + private static readonly Action LogRejectedOrigin = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(2, nameof(LogRejectedOrigin)), + "Rejected MCP HTTP request with Origin '{Origin}'."); + + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly ReplMcpHttpSecurityOptions _options; + + public ReplMcpHttpSecurityMiddleware( + RequestDelegate next, + ILogger logger, + ReplMcpHttpSecurityOptions options) + { + _next = next; + _logger = logger; + _options = options; + } + + public async Task InvokeAsync(HttpContext context) + { + if (!IsAllowedHost(context.Request.Host.Host)) + { + ReplMcpHttpDiagnostics.RejectedRequests.Add(1, new KeyValuePair("reason", "host")); + LogRejectedHost(_logger, context.Request.Host.Value ?? string.Empty, null); + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + + if (!IsAllowedOrigin(context.Request.Headers.Origin.ToString())) + { + ReplMcpHttpDiagnostics.RejectedRequests.Add(1, new KeyValuePair("reason", "origin")); + LogRejectedOrigin(_logger, context.Request.Headers.Origin.ToString(), null); + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + + using var activity = ReplMcpHttpDiagnostics.ActivitySource.StartActivity( + "Repl.Mcp.HttpRequest", + ActivityKind.Server); + await _next(context).ConfigureAwait(false); + } + + private bool IsAllowedHost(string? host) + { + if (_options.AllowAnyHost) + { + return true; + } + + if (string.IsNullOrWhiteSpace(host)) + { + return false; + } + + var normalized = NormalizeHost(host); + return _options.AllowedHosts + .Select(NormalizeHost) + .Contains(normalized, StringComparer.OrdinalIgnoreCase); + } + + private bool IsAllowedOrigin(string origin) + { + if (_options.AllowAnyOrigin || string.IsNullOrWhiteSpace(origin)) + { + return true; + } + + return _options.AllowedOrigins.Contains(origin, StringComparer.OrdinalIgnoreCase); + } + + private static string NormalizeHost(string host) + { + var normalized = host.Trim(); + if (normalized.Length > 1 && normalized[0] == '[') + { + var closingBracket = normalized.IndexOf(']', StringComparison.Ordinal); + if (closingBracket >= 0) + { + return normalized[..(closingBracket + 1)]; + } + } + + if (ContainsMultipleColons(normalized)) + { + return normalized; + } + + var colon = normalized.LastIndexOf(':'); + return colon > 0 ? normalized[..colon] : normalized; + } + + private static bool ContainsMultipleColons(string value) + { + var found = false; + foreach (var character in value) + { + if (character != ':') + { + continue; + } + + if (found) + { + return true; + } + + found = true; + } + + return false; + } +} diff --git a/src/Repl.Mcp.AspNetCore/ReplMcpHttpSecurityOptions.cs b/src/Repl.Mcp.AspNetCore/ReplMcpHttpSecurityOptions.cs new file mode 100644 index 0000000..38e10fe --- /dev/null +++ b/src/Repl.Mcp.AspNetCore/ReplMcpHttpSecurityOptions.cs @@ -0,0 +1,33 @@ +namespace Repl.Mcp.AspNetCore; + +/// +/// Configures defensive HTTP checks for self-hosted Repl MCP endpoints. +/// +public sealed class ReplMcpHttpSecurityOptions +{ + /// + /// Gets or sets the allowed HTTP Host header values. Ports are ignored when matching. + /// + public IList AllowedHosts { get; } = + [ + "localhost", + "127.0.0.1", + "::1", + "[::1]", + ]; + + /// + /// Gets the allowed browser Origin header values. + /// + public IList AllowedOrigins { get; } = []; + + /// + /// Gets or sets a value indicating whether any HTTP Host header should be accepted. + /// + public bool AllowAnyHost { get; set; } + + /// + /// Gets or sets a value indicating whether any browser Origin header should be accepted. + /// + public bool AllowAnyOrigin { get; set; } +} diff --git a/src/Repl.Mcp.AspNetCore/ReplMcpHttpServer.cs b/src/Repl.Mcp.AspNetCore/ReplMcpHttpServer.cs index 8313928..a2ef473 100644 --- a/src/Repl.Mcp.AspNetCore/ReplMcpHttpServer.cs +++ b/src/Repl.Mcp.AspNetCore/ReplMcpHttpServer.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Repl.Mcp.AspNetCore; @@ -46,24 +47,19 @@ internal static async Task RunAsync( options.AllowRemote); var builder = WebApplication.CreateSlimBuilder(); - builder.Logging.ClearProviders(); + builder.Logging.AddSimpleConsole(); + options.ConfigureBuilder?.Invoke(builder); builder.WebHost.UseUrls(binding.ListenUrl); - builder.Services.AddReplMcpHttp(replApp, http => - { - var httpOptions = options.ToHttpOptions(); - http.ConfigureServer = httpOptions.ConfigureServer; - http.ConfigureTransport = httpOptions.ConfigureTransport; - http.EnableAuthorizationFilters = httpOptions.EnableAuthorizationFilters; - http.Stateless = httpOptions.Stateless; - http.PerSessionExecutionContext = httpOptions.PerSessionExecutionContext; - http.IdleTimeout = httpOptions.IdleTimeout; - http.MaxIdleSessionCount = httpOptions.MaxIdleSessionCount; - }); + builder.Services.AddSingleton(options.Security); + builder.Services.AddReplMcpHttp(replApp, options.Http); var webApp = builder.Build(); await using (webApp.ConfigureAwait(false)) { - webApp.MapReplMcp(binding.Path); + webApp.UseMiddleware(); + options.ConfigureApp?.Invoke(webApp); + var endpoint = webApp.MapReplMcp(binding.Path); + options.ConfigureEndpoint?.Invoke(endpoint); if (!options.Quiet && output is not null) { @@ -80,6 +76,9 @@ await output.WriteLineAsync( { await Task.Delay(Timeout.InfiniteTimeSpan, TimeProvider.System, cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } finally { await webApp.StopAsync(CancellationToken.None).ConfigureAwait(false); diff --git a/src/Repl.Mcp.AspNetCore/ReplMcpHttpServerOptions.cs b/src/Repl.Mcp.AspNetCore/ReplMcpHttpServerOptions.cs index 23f981c..4abdff4 100644 --- a/src/Repl.Mcp.AspNetCore/ReplMcpHttpServerOptions.cs +++ b/src/Repl.Mcp.AspNetCore/ReplMcpHttpServerOptions.cs @@ -1,5 +1,5 @@ -using ModelContextProtocol.AspNetCore; -using Repl.Mcp; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; namespace Repl.Mcp.AspNetCore; @@ -11,17 +11,17 @@ public sealed class ReplMcpHttpServerOptions /// /// Default local-only host. /// - public const string DefaultHost = "127.0.0.1"; + public static readonly string DefaultHost = "127.0.0.1"; /// /// Default HTTP port. The digits correspond to "repl" on a phone keypad. /// - public const int DefaultPort = 7375; + public static readonly int DefaultPort = 7375; /// /// Default Streamable HTTP endpoint path. /// - public const string DefaultPath = "/mcp"; + public static readonly string DefaultPath = "/mcp"; /// /// Gets or sets the hostname or IP address to bind. @@ -49,64 +49,79 @@ public sealed class ReplMcpHttpServerOptions public bool Quiet { get; set; } /// - /// Gets or sets a callback for configuring the Repl MCP server surface created for each MCP session. + /// Gets the shared Repl MCP HTTP transport options. /// - public Action? ConfigureServer { get; set; } + public ReplMcpHttpOptions Http { get; } = new(); /// - /// Gets or sets a callback for configuring the underlying MCP HTTP transport. + /// Gets the self-hosted HTTP security options. /// - public Action? ConfigureTransport { get; set; } + public ReplMcpHttpSecurityOptions Security { get; } = new(); /// - /// Gets or sets a value indicating whether MCP authorization filters should be registered. + /// Gets or sets a callback invoked before the inner is built. /// - public bool EnableAuthorizationFilters { get; set; } + public Action? ConfigureBuilder { get; set; } /// - /// Gets or sets a value indicating whether HTTP sessions should be stateless. + /// Gets or sets a callback invoked after the inner is built and before MCP is mapped. /// - public bool Stateless { get; set; } + public Action? ConfigureApp { get; set; } /// - /// Gets or sets a value indicating whether a single execution context should be used per MCP session. + /// Gets or sets a callback invoked after the MCP endpoint is mapped. /// - public bool PerSessionExecutionContext { get; set; } + public Action? ConfigureEndpoint { get; set; } - /// - /// Gets or sets the amount of idle time after which a stateful MCP session expires. - /// - public TimeSpan? IdleTimeout { get; set; } - - /// - /// Gets or sets the maximum number of idle stateful MCP sessions to keep in memory. - /// - public int? MaxIdleSessionCount { get; set; } + internal ReplMcpHttpServerOptions Clone() + { + var clone = new ReplMcpHttpServerOptions + { + Host = Host, + Port = Port, + Path = Path, + AllowRemote = AllowRemote, + Quiet = Quiet, + ConfigureBuilder = ConfigureBuilder, + ConfigureApp = ConfigureApp, + ConfigureEndpoint = ConfigureEndpoint, + }; + + CopyNestedOptionsTo(clone); + return clone; + } + + internal void CopyNestedOptionsTo(ReplMcpHttpServerOptions target) + { + CopyHttpOptions(Http, target.Http); + CopySecurityOptions(Security, target.Security); + } - internal ReplMcpHttpOptions ToHttpOptions() => new() + private static void CopyHttpOptions(ReplMcpHttpOptions source, ReplMcpHttpOptions target) { - ConfigureServer = ConfigureServer, - ConfigureTransport = ConfigureTransport, - EnableAuthorizationFilters = EnableAuthorizationFilters, - Stateless = Stateless, - PerSessionExecutionContext = PerSessionExecutionContext, - IdleTimeout = IdleTimeout, - MaxIdleSessionCount = MaxIdleSessionCount, - }; - - internal ReplMcpHttpServerOptions Clone() => new() + target.ConfigureServer = source.ConfigureServer; + target.ConfigureTransport = source.ConfigureTransport; + target.EnableAuthorizationFilters = source.EnableAuthorizationFilters; + target.Stateless = source.Stateless; + target.PerSessionExecutionContext = source.PerSessionExecutionContext; + target.IdleTimeout = source.IdleTimeout; + target.MaxIdleSessionCount = source.MaxIdleSessionCount; + } + + private static void CopySecurityOptions(ReplMcpHttpSecurityOptions source, ReplMcpHttpSecurityOptions target) { - Host = Host, - Port = Port, - Path = Path, - AllowRemote = AllowRemote, - Quiet = Quiet, - ConfigureServer = ConfigureServer, - ConfigureTransport = ConfigureTransport, - EnableAuthorizationFilters = EnableAuthorizationFilters, - Stateless = Stateless, - PerSessionExecutionContext = PerSessionExecutionContext, - IdleTimeout = IdleTimeout, - MaxIdleSessionCount = MaxIdleSessionCount, - }; + target.AllowAnyHost = source.AllowAnyHost; + target.AllowAnyOrigin = source.AllowAnyOrigin; + target.AllowedHosts.Clear(); + foreach (var host in source.AllowedHosts) + { + target.AllowedHosts.Add(host); + } + + target.AllowedOrigins.Clear(); + foreach (var origin in source.AllowedOrigins) + { + target.AllowedOrigins.Add(origin); + } + } } diff --git a/src/Repl.Mcp.AspNetCore/ReplMcpHttpServiceCollectionExtensions.cs b/src/Repl.Mcp.AspNetCore/ReplMcpHttpServiceCollectionExtensions.cs index 059a342..709849b 100644 --- a/src/Repl.Mcp.AspNetCore/ReplMcpHttpServiceCollectionExtensions.cs +++ b/src/Repl.Mcp.AspNetCore/ReplMcpHttpServiceCollectionExtensions.cs @@ -23,11 +23,26 @@ public static IMcpServerBuilder AddReplMcpHttp( ReplApp app, Action? configure = null) { - ArgumentNullException.ThrowIfNull(services); - ArgumentNullException.ThrowIfNull(app); - var options = new ReplMcpHttpOptions(); configure?.Invoke(options); + return services.AddReplMcpHttp(app, options); + } + + /// + /// Registers Repl MCP server services using the MCP Streamable HTTP transport. + /// + /// Service collection. + /// The Repl app to expose over MCP. + /// HTTP MCP configuration. + /// The MCP server builder. + public static IMcpServerBuilder AddReplMcpHttp( + this IServiceCollection services, + ReplApp app, + ReplMcpHttpOptions options) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(app); + ArgumentNullException.ThrowIfNull(options); services.TryAddSingleton(app); diff --git a/src/Repl.McpTests/Given_McpHttpSelfHostSecurity.cs b/src/Repl.McpTests/Given_McpHttpSelfHostSecurity.cs new file mode 100644 index 0000000..2a392cb --- /dev/null +++ b/src/Repl.McpTests/Given_McpHttpSelfHostSecurity.cs @@ -0,0 +1,128 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Repl.Mcp.AspNetCore; + +namespace Repl.McpTests; + +[TestClass] +public sealed class Given_McpHttpSelfHostSecurity +{ + [TestMethod] + public async Task When_RequestHasLoopbackHostAndNoOrigin_Then_RequestContinues() + { + var called = false; + var middleware = CreateMiddleware(_ => + { + called = true; + return Task.CompletedTask; + }); + var context = CreateContext("127.0.0.1:7375"); + + await middleware.InvokeAsync(context); + + called.Should().BeTrue(); + context.Response.StatusCode.Should().Be(StatusCodes.Status200OK); + } + + [TestMethod] + public async Task When_RequestHasUnexpectedHost_Then_RequestIsRejected() + { + var called = false; + var middleware = CreateMiddleware(_ => + { + called = true; + return Task.CompletedTask; + }); + var context = CreateContext("example.com"); + + await middleware.InvokeAsync(context); + + called.Should().BeFalse(); + context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + } + + [TestMethod] + public async Task When_RequestHasUnexpectedBrowserOrigin_Then_RequestIsRejected() + { + var called = false; + var middleware = CreateMiddleware(_ => + { + called = true; + return Task.CompletedTask; + }); + var context = CreateContext("127.0.0.1:7375", "https://example.com"); + + await middleware.InvokeAsync(context); + + called.Should().BeFalse(); + context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + } + + [TestMethod] + public async Task When_RequestHasAllowedBrowserOrigin_Then_RequestContinues() + { + var called = false; + var options = new ReplMcpHttpSecurityOptions(); + options.AllowedOrigins.Add("https://trusted.example"); + var middleware = CreateMiddleware(_ => + { + called = true; + return Task.CompletedTask; + }, options); + var context = CreateContext("localhost:7375", "https://trusted.example"); + + await middleware.InvokeAsync(context); + + called.Should().BeTrue(); + context.Response.StatusCode.Should().Be(StatusCodes.Status200OK); + } + + [TestMethod] + public void When_ServerOptionsAreCloned_Then_NestedHttpAndSecurityOptionsAreCopied() + { + var options = new ReplMcpHttpServerOptions + { + Host = "localhost", + Port = 5555, + Path = "/tools", + AllowRemote = true, + Quiet = true, + }; + options.Http.IdleTimeout = TimeSpan.FromMinutes(2); + options.Http.MaxIdleSessionCount = 3; + options.Http.PerSessionExecutionContext = true; + options.Security.AllowAnyHost = true; + options.Security.AllowedOrigins.Add("https://trusted.example"); + + var clone = options.Clone(); + + clone.Host.Should().Be("localhost"); + clone.Port.Should().Be(5555); + clone.Path.Should().Be("/tools"); + clone.AllowRemote.Should().BeTrue(); + clone.Quiet.Should().BeTrue(); + clone.Http.IdleTimeout.Should().Be(TimeSpan.FromMinutes(2)); + clone.Http.MaxIdleSessionCount.Should().Be(3); + clone.Http.PerSessionExecutionContext.Should().BeTrue(); + clone.Security.AllowAnyHost.Should().BeTrue(); + clone.Security.AllowedOrigins.Should().ContainSingle("https://trusted.example"); + clone.Security.AllowedOrigins.Should().NotBeSameAs(options.Security.AllowedOrigins); + } + + private static ReplMcpHttpSecurityMiddleware CreateMiddleware( + RequestDelegate next, + ReplMcpHttpSecurityOptions? options = null) => + new(next, NullLogger.Instance, options ?? new ReplMcpHttpSecurityOptions()); + + private static DefaultHttpContext CreateContext(string host, string? origin = null) + { + var context = new DefaultHttpContext(); + context.Request.Host = HostString.FromUriComponent(host); + if (origin is not null) + { + context.Request.Headers.Origin = origin; + } + + return context; + } +} From d5dece12a47adabbcdee78a329fa8b03cf9efd5d Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 22 Jun 2026 16:59:43 -0400 Subject: [PATCH 7/9] Manage MCP HTTP session lifetimes --- .../ReplMcpHttpDiagnostics.cs | 3 + .../ReplMcpHttpSecurityOptions.cs | 41 ++++++++- src/Repl.Mcp.AspNetCore/ReplMcpHttpServer.cs | 11 +++ .../ReplMcpHttpServerOptions.cs | 9 ++ .../ReplMcpHttpServiceCollectionExtensions.cs | 91 ++++++++++++++++--- .../Given_McpHttpSelfHostSecurity.cs | 87 ++++++++++++++++++ 6 files changed, 227 insertions(+), 15 deletions(-) diff --git a/src/Repl.Mcp.AspNetCore/ReplMcpHttpDiagnostics.cs b/src/Repl.Mcp.AspNetCore/ReplMcpHttpDiagnostics.cs index a549e4b..3a807c4 100644 --- a/src/Repl.Mcp.AspNetCore/ReplMcpHttpDiagnostics.cs +++ b/src/Repl.Mcp.AspNetCore/ReplMcpHttpDiagnostics.cs @@ -10,6 +10,9 @@ internal static class ReplMcpHttpDiagnostics public static readonly Meter Meter = new(MeterName); public static readonly ActivitySource ActivitySource = new(ActivitySourceName); + public static readonly Counter SessionsStarted = Meter.CreateCounter("repl.mcp.http.sessions.started"); + public static readonly Counter SessionsEnded = Meter.CreateCounter("repl.mcp.http.sessions.ended"); + public static readonly UpDownCounter SessionsActive = Meter.CreateUpDownCounter("repl.mcp.http.sessions.active"); public static readonly Counter RejectedRequests = Meter.CreateCounter("repl.mcp.http.requests.rejected"); public static readonly Counter StartupFailures = Meter.CreateCounter("repl.mcp.http.startup.failures"); } diff --git a/src/Repl.Mcp.AspNetCore/ReplMcpHttpSecurityOptions.cs b/src/Repl.Mcp.AspNetCore/ReplMcpHttpSecurityOptions.cs index 38e10fe..e5d78a2 100644 --- a/src/Repl.Mcp.AspNetCore/ReplMcpHttpSecurityOptions.cs +++ b/src/Repl.Mcp.AspNetCore/ReplMcpHttpSecurityOptions.cs @@ -5,10 +5,7 @@ namespace Repl.Mcp.AspNetCore; /// public sealed class ReplMcpHttpSecurityOptions { - /// - /// Gets or sets the allowed HTTP Host header values. Ports are ignored when matching. - /// - public IList AllowedHosts { get; } = + private static readonly string[] DefaultAllowedHosts = [ "localhost", "127.0.0.1", @@ -16,6 +13,11 @@ public sealed class ReplMcpHttpSecurityOptions "[::1]", ]; + /// + /// Gets or sets the allowed HTTP Host header values. Ports are ignored when matching. + /// + public IList AllowedHosts { get; } = [.. DefaultAllowedHosts]; + /// /// Gets the allowed browser Origin header values. /// @@ -30,4 +32,35 @@ public sealed class ReplMcpHttpSecurityOptions /// Gets or sets a value indicating whether any browser Origin header should be accepted. /// public bool AllowAnyOrigin { get; set; } + + internal bool UsesDefaultAllowedHosts() + { + if (AllowAnyHost || AllowedHosts.Count != DefaultAllowedHosts.Length) + { + return false; + } + + foreach (var defaultHost in DefaultAllowedHosts) + { + if (!ContainsAllowedHost(defaultHost)) + { + return false; + } + } + + return true; + } + + private bool ContainsAllowedHost(string host) + { + foreach (var allowedHost in AllowedHosts) + { + if (StringComparer.OrdinalIgnoreCase.Equals(allowedHost, host)) + { + return true; + } + } + + return false; + } } diff --git a/src/Repl.Mcp.AspNetCore/ReplMcpHttpServer.cs b/src/Repl.Mcp.AspNetCore/ReplMcpHttpServer.cs index a2ef473..17f5ad3 100644 --- a/src/Repl.Mcp.AspNetCore/ReplMcpHttpServer.cs +++ b/src/Repl.Mcp.AspNetCore/ReplMcpHttpServer.cs @@ -50,6 +50,7 @@ internal static async Task RunAsync( builder.Logging.AddSimpleConsole(); options.ConfigureBuilder?.Invoke(builder); builder.WebHost.UseUrls(binding.ListenUrl); + ApplyBindingSecurityDefaults(binding, options.Security); builder.Services.AddSingleton(options.Security); builder.Services.AddReplMcpHttp(replApp, options.Http); @@ -85,4 +86,14 @@ await output.WriteLineAsync( } } } + + internal static void ApplyBindingSecurityDefaults( + McpHttpBinding binding, + ReplMcpHttpSecurityOptions security) + { + if (binding.AllowsRemote && security.UsesDefaultAllowedHosts()) + { + security.AllowAnyHost = true; + } + } } diff --git a/src/Repl.Mcp.AspNetCore/ReplMcpHttpServerOptions.cs b/src/Repl.Mcp.AspNetCore/ReplMcpHttpServerOptions.cs index 4abdff4..26c7c67 100644 --- a/src/Repl.Mcp.AspNetCore/ReplMcpHttpServerOptions.cs +++ b/src/Repl.Mcp.AspNetCore/ReplMcpHttpServerOptions.cs @@ -23,6 +23,15 @@ public sealed class ReplMcpHttpServerOptions /// public static readonly string DefaultPath = "/mcp"; + /// + /// Initializes a new instance of the class. + /// + public ReplMcpHttpServerOptions() + { + Http.IdleTimeout = TimeSpan.FromMinutes(30); + Http.MaxIdleSessionCount = 100; + } + /// /// Gets or sets the hostname or IP address to bind. /// diff --git a/src/Repl.Mcp.AspNetCore/ReplMcpHttpServiceCollectionExtensions.cs b/src/Repl.Mcp.AspNetCore/ReplMcpHttpServiceCollectionExtensions.cs index 709849b..5bd1aec 100644 --- a/src/Repl.Mcp.AspNetCore/ReplMcpHttpServiceCollectionExtensions.cs +++ b/src/Repl.Mcp.AspNetCore/ReplMcpHttpServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using ModelContextProtocol.AspNetCore; @@ -11,6 +12,8 @@ namespace Repl.Mcp.AspNetCore; /// public static class ReplMcpHttpServiceCollectionExtensions { + private static readonly object SessionItemKey = new(); + /// /// Registers Repl MCP server services using the MCP Streamable HTTP transport. /// @@ -52,26 +55,80 @@ public static IMcpServerBuilder AddReplMcpHttp( builder.AddAuthorizationFilters(); } - builder.WithHttpTransport(http => - { - ApplyTransportOptions(http, options); - options.ConfigureTransport?.Invoke(http); + builder.WithHttpTransport(http => ConfigureTransport(http, app, options)); + + return builder; + } + + internal static void ConfigureTransport( + HttpServerTransportOptions http, + ReplApp app, + ReplMcpHttpOptions options) + { + ApplyTransportOptions(http, options); + options.ConfigureTransport?.Invoke(http); + ConfigureSessionOptions(http, app, options); + ConfigureRunSessionHandler(http); + } - var configureSessionOptions = http.ConfigureSessionOptions; - http.ConfigureSessionOptions = async (context, serverOptions, cancellationToken) => + private static void ConfigureSessionOptions( + HttpServerTransportOptions http, + ReplApp app, + ReplMcpHttpOptions options) + { + var configureSessionOptions = http.ConfigureSessionOptions; + http.ConfigureSessionOptions = async (context, serverOptions, cancellationToken) => + { + var sessionServices = new CompositeServiceProvider(context.RequestServices, app.Services); + var session = app.CreateMcpServerSession(sessionServices, options.ConfigureServer); + try { - var sessionServices = new CompositeServiceProvider(context.RequestServices, app.Services); - var session = app.CreateMcpServerSession(sessionServices, options.ConfigureServer); CopyServerOptions(session.ServerOptions, serverOptions); + context.Items[SessionItemKey] = session; if (configureSessionOptions is not null) { await configureSessionOptions(context, serverOptions, cancellationToken).ConfigureAwait(false); } - }; - }); + } + catch + { + context.Items.Remove(SessionItemKey); + session.Dispose(); + throw; + } + }; + } - return builder; + private static void ConfigureRunSessionHandler(HttpServerTransportOptions http) + { +#pragma warning disable MCPEXP002 + var runSessionHandler = http.RunSessionHandler; + http.RunSessionHandler = async (context, server, cancellationToken) => + { + var session = TakeSession(context); + ReplMcpHttpDiagnostics.SessionsStarted.Add(1); + ReplMcpHttpDiagnostics.SessionsActive.Add(1); + try + { + if (runSessionHandler is not null) + { + await runSessionHandler(context, server, cancellationToken).ConfigureAwait(false); + } + else + { + await server.RunAsync(cancellationToken).ConfigureAwait(false); + } + } + finally + { + session ??= TakeSession(context); + session?.Dispose(); + ReplMcpHttpDiagnostics.SessionsEnded.Add(1); + ReplMcpHttpDiagnostics.SessionsActive.Add(-1); + } + }; +#pragma warning restore MCPEXP002 } private static void ApplyTransportOptions( @@ -98,4 +155,16 @@ private static void CopyServerOptions(McpServerOptions source, McpServerOptions target.Capabilities = source.Capabilities; target.Handlers = source.Handlers; } + + private static ReplMcpServerSession? TakeSession(HttpContext context) + { + if (context.Items.TryGetValue(SessionItemKey, out var value) + && value is ReplMcpServerSession session) + { + context.Items.Remove(SessionItemKey); + return session; + } + + return null; + } } diff --git a/src/Repl.McpTests/Given_McpHttpSelfHostSecurity.cs b/src/Repl.McpTests/Given_McpHttpSelfHostSecurity.cs index 2a392cb..6f91eef 100644 --- a/src/Repl.McpTests/Given_McpHttpSelfHostSecurity.cs +++ b/src/Repl.McpTests/Given_McpHttpSelfHostSecurity.cs @@ -1,5 +1,8 @@ using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol.AspNetCore; +using ModelContextProtocol.Server; using Repl.Mcp.AspNetCore; namespace Repl.McpTests; @@ -109,6 +112,90 @@ public void When_ServerOptionsAreCloned_Then_NestedHttpAndSecurityOptionsAreCopi clone.Security.AllowedOrigins.Should().NotBeSameAs(options.Security.AllowedOrigins); } + [TestMethod] + public void When_ServerOptionsAreCreated_Then_UsesConservativeStatefulLimits() + { + var options = new ReplMcpHttpServerOptions(); + + options.Http.IdleTimeout.Should().Be(TimeSpan.FromMinutes(30)); + options.Http.MaxIdleSessionCount.Should().Be(100); + } + + [TestMethod] + public void When_RemoteBindingUsesDefaultSecurity_Then_AnyHostIsAllowedButOriginsStayRestricted() + { + var binding = McpHttpBindingFactory.Create("0.0.0.0", 7375, "/mcp", allowRemote: true); + var security = new ReplMcpHttpSecurityOptions(); + + ReplMcpHttpServer.ApplyBindingSecurityDefaults(binding, security); + + security.AllowAnyHost.Should().BeTrue(); + security.AllowAnyOrigin.Should().BeFalse(); + } + + [TestMethod] + public void When_RemoteBindingUsesCustomHostList_Then_HostListIsPreserved() + { + var binding = McpHttpBindingFactory.Create("0.0.0.0", 7375, "/mcp", allowRemote: true); + var security = new ReplMcpHttpSecurityOptions(); + security.AllowedHosts.Clear(); + security.AllowedHosts.Add("internal.example"); + + ReplMcpHttpServer.ApplyBindingSecurityDefaults(binding, security); + + security.AllowAnyHost.Should().BeFalse(); + security.AllowedHosts.Should().ContainSingle("internal.example"); + } + + [TestMethod] + public async Task When_HttpSessionCompletes_Then_SessionItemIsReleased() + { + var app = ReplApp.Create(); + app.Map("ping", () => "pong"); + var transport = new HttpServerTransportOptions(); + var runCalled = false; +#pragma warning disable MCPEXP002 + transport.RunSessionHandler = (_, _, _) => + { + runCalled = true; + return Task.CompletedTask; + }; +#pragma warning restore MCPEXP002 + ReplMcpHttpServiceCollectionExtensions.ConfigureTransport(transport, app, new ReplMcpHttpOptions()); + var context = CreateContext("127.0.0.1:7375"); + context.RequestServices = new ServiceCollection().BuildServiceProvider(); + + await transport.ConfigureSessionOptions!(context, new McpServerOptions(), CancellationToken.None); + context.Items.Should().NotBeEmpty(); + +#pragma warning disable MCPEXP002 + await transport.RunSessionHandler!(context, null!, CancellationToken.None); +#pragma warning restore MCPEXP002 + + runCalled.Should().BeTrue(); + context.Items.Should().BeEmpty(); + } + + [TestMethod] + public async Task When_ExternalSessionConfigurationFails_Then_SessionItemIsReleased() + { + var app = ReplApp.Create(); + app.Map("ping", () => "pong"); + var transport = new HttpServerTransportOptions + { + ConfigureSessionOptions = (_, _, _) => throw new InvalidOperationException("boom"), + }; + ReplMcpHttpServiceCollectionExtensions.ConfigureTransport(transport, app, new ReplMcpHttpOptions()); + var context = CreateContext("127.0.0.1:7375"); + context.RequestServices = new ServiceCollection().BuildServiceProvider(); + + var act = () => transport.ConfigureSessionOptions!(context, new McpServerOptions(), CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("boom"); + context.Items.Should().BeEmpty(); + } + private static ReplMcpHttpSecurityMiddleware CreateMiddleware( RequestDelegate next, ReplMcpHttpSecurityOptions? options = null) => From 004775cea910d912c673e64be25e35409a15b10d Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 22 Jun 2026 17:03:56 -0400 Subject: [PATCH 8/9] Document MCP HTTP hosting guidance --- .../McpHttpBindingFactory.cs | 2 +- src/Repl.Mcp.AspNetCore/McpHttpModule.cs | 3 +- src/Repl.Mcp.AspNetCore/README.md | 36 ++++++++++++++++++- .../ReplMcpEndpointRouteBuilderExtensions.cs | 10 +++++- 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/Repl.Mcp.AspNetCore/McpHttpBindingFactory.cs b/src/Repl.Mcp.AspNetCore/McpHttpBindingFactory.cs index 89303da..5bec0f8 100644 --- a/src/Repl.Mcp.AspNetCore/McpHttpBindingFactory.cs +++ b/src/Repl.Mcp.AspNetCore/McpHttpBindingFactory.cs @@ -55,7 +55,7 @@ private static string NormalizePath(string? path) normalized = "/" + normalized; } - return normalized.Length == 0 ? ReplMcpHttpServerOptions.DefaultPath : normalized; + return normalized; } private static bool IsLoopbackHost(string host) diff --git a/src/Repl.Mcp.AspNetCore/McpHttpModule.cs b/src/Repl.Mcp.AspNetCore/McpHttpModule.cs index 8a70256..84db5f8 100644 --- a/src/Repl.Mcp.AspNetCore/McpHttpModule.cs +++ b/src/Repl.Mcp.AspNetCore/McpHttpModule.cs @@ -64,7 +64,8 @@ await ReplMcpHttpServer.RunAsync( } catch (Exception ex) { - await io.Output.WriteLineAsync($"Error: {ex.Message}").ConfigureAwait(false); + ReplMcpHttpDiagnostics.StartupFailures.Add(1); + await io.Error.WriteLineAsync(ex.ToString()).ConfigureAwait(false); return Results.Exit(1); } } diff --git a/src/Repl.Mcp.AspNetCore/README.md b/src/Repl.Mcp.AspNetCore/README.md index 38791eb..b0ef54c 100644 --- a/src/Repl.Mcp.AspNetCore/README.md +++ b/src/Repl.Mcp.AspNetCore/README.md @@ -11,6 +11,8 @@ var app = builder.Build(); app.MapReplMcp("/mcp"); ``` +`MapReplMcp()` maps the default `/mcp` endpoint. + Use ASP.NET Core middleware and endpoint conventions for production concerns such as authentication, authorization, CORS, HTTPS, and reverse proxy hosting. @@ -25,6 +27,16 @@ app.UseAuthorization(); app.MapReplMcp("/mcp").RequireAuthorization(); ``` +For per-installation transport customization, configure the hosted transport: + +```csharp +builder.Services.AddReplMcpHttp(replApp, http => +{ + http.IdleTimeout = TimeSpan.FromMinutes(15); + http.MaxIdleSessionCount = 50; +}); +``` + ## Self-Hosted CLI Register the local HTTP command on a Repl app: @@ -41,10 +53,32 @@ myapp mcp httpserve ``` The default endpoint is `http://127.0.0.1:7375/mcp`. The port digits map to -`repl` on a phone keypad. Non-loopback bindings require an explicit opt-in: +`repl` on a phone keypad. The self-hosted command is intended for local agents +and trusted development networks. Non-loopback bindings require an explicit +opt-in: ```bash myapp mcp httpserve --host 0.0.0.0 --allow-remote ``` +Loopback self-hosting rejects browser requests with unexpected `Origin` headers +and restricts the HTTP `Host` header to loopback names. Remote opt-in relaxes +the Host restriction for trusted networks, but does not add authentication or +TLS. Use hosted ASP.NET Core when the endpoint needs production-grade auth, +certificates, reverse proxy integration, or custom network policy. + +Self-hosted apps can expose limited advanced hooks: + +```csharp +var replApp = ReplApp.Create() + .UseMcpHttpServer(options => + { + options.ConfigureBuilder = builder => + builder.Configuration.AddEnvironmentVariables("MYAPP_"); + + options.ConfigureApp = app => + app.UseForwardedHeaders(); + }); +``` + The `http` and `http-serve` aliases also resolve to `httpserve`. diff --git a/src/Repl.Mcp.AspNetCore/ReplMcpEndpointRouteBuilderExtensions.cs b/src/Repl.Mcp.AspNetCore/ReplMcpEndpointRouteBuilderExtensions.cs index f31a921..6dcc8ad 100644 --- a/src/Repl.Mcp.AspNetCore/ReplMcpEndpointRouteBuilderExtensions.cs +++ b/src/Repl.Mcp.AspNetCore/ReplMcpEndpointRouteBuilderExtensions.cs @@ -16,9 +16,17 @@ public static class ReplMcpEndpointRouteBuilderExtensions /// An endpoint convention builder. public static IEndpointConventionBuilder MapReplMcp( this IEndpointRouteBuilder endpoints, - string pattern = "/mcp") + string pattern) { ArgumentNullException.ThrowIfNull(endpoints); return endpoints.MapMcp(pattern); } + + /// + /// Maps the Repl MCP Streamable HTTP endpoint at the default path. + /// + /// Endpoint route builder. + /// An endpoint convention builder. + public static IEndpointConventionBuilder MapReplMcp(this IEndpointRouteBuilder endpoints) => + endpoints.MapReplMcp(ReplMcpHttpServerOptions.DefaultPath); } From 9cfacf653736f518bcb407479df7db14c5206146 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Wed, 24 Jun 2026 18:00:18 -0400 Subject: [PATCH 9/9] Address MCP HTTP review feedback --- src/Repl.Mcp.AspNetCore/McpHttpModule.cs | 2 +- .../ReplMcpHttpSecurityOptions.cs | 24 +++---------------- src/Repl.Mcp.AspNetCore/ReplMcpHttpServer.cs | 7 ++++++ .../ReplMcpHttpServiceCollectionExtensions.cs | 3 +-- 4 files changed, 12 insertions(+), 24 deletions(-) diff --git a/src/Repl.Mcp.AspNetCore/McpHttpModule.cs b/src/Repl.Mcp.AspNetCore/McpHttpModule.cs index 84db5f8..6f76ff5 100644 --- a/src/Repl.Mcp.AspNetCore/McpHttpModule.cs +++ b/src/Repl.Mcp.AspNetCore/McpHttpModule.cs @@ -62,7 +62,7 @@ await ReplMcpHttpServer.RunAsync( { return Results.Exit(0); } - catch (Exception ex) + catch (InvalidOperationException ex) { ReplMcpHttpDiagnostics.StartupFailures.Add(1); await io.Error.WriteLineAsync(ex.ToString()).ConfigureAwait(false); diff --git a/src/Repl.Mcp.AspNetCore/ReplMcpHttpSecurityOptions.cs b/src/Repl.Mcp.AspNetCore/ReplMcpHttpSecurityOptions.cs index e5d78a2..776f2b1 100644 --- a/src/Repl.Mcp.AspNetCore/ReplMcpHttpSecurityOptions.cs +++ b/src/Repl.Mcp.AspNetCore/ReplMcpHttpSecurityOptions.cs @@ -40,27 +40,9 @@ internal bool UsesDefaultAllowedHosts() return false; } - foreach (var defaultHost in DefaultAllowedHosts) - { - if (!ContainsAllowedHost(defaultHost)) - { - return false; - } - } - - return true; + return !DefaultAllowedHosts.Any(defaultHost => !ContainsAllowedHost(defaultHost)); } - private bool ContainsAllowedHost(string host) - { - foreach (var allowedHost in AllowedHosts) - { - if (StringComparer.OrdinalIgnoreCase.Equals(allowedHost, host)) - { - return true; - } - } - - return false; - } + private bool ContainsAllowedHost(string host) => + AllowedHosts.Any(allowedHost => StringComparer.OrdinalIgnoreCase.Equals(allowedHost, host)); } diff --git a/src/Repl.Mcp.AspNetCore/ReplMcpHttpServer.cs b/src/Repl.Mcp.AspNetCore/ReplMcpHttpServer.cs index 17f5ad3..e159dfe 100644 --- a/src/Repl.Mcp.AspNetCore/ReplMcpHttpServer.cs +++ b/src/Repl.Mcp.AspNetCore/ReplMcpHttpServer.cs @@ -10,6 +10,12 @@ namespace Repl.Mcp.AspNetCore; /// public static class ReplMcpHttpServer { + private static readonly Action LogShutdownRequested = + LoggerMessage.Define( + LogLevel.Debug, + new EventId(1, nameof(LogShutdownRequested)), + "MCP HTTP server shutdown requested."); + /// /// Runs a self-hosted Repl MCP Streamable HTTP server until cancellation is requested. /// @@ -79,6 +85,7 @@ await output.WriteLineAsync( } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { + LogShutdownRequested(webApp.Logger, null); } finally { diff --git a/src/Repl.Mcp.AspNetCore/ReplMcpHttpServiceCollectionExtensions.cs b/src/Repl.Mcp.AspNetCore/ReplMcpHttpServiceCollectionExtensions.cs index 5bd1aec..2633829 100644 --- a/src/Repl.Mcp.AspNetCore/ReplMcpHttpServiceCollectionExtensions.cs +++ b/src/Repl.Mcp.AspNetCore/ReplMcpHttpServiceCollectionExtensions.cs @@ -122,8 +122,7 @@ private static void ConfigureRunSessionHandler(HttpServerTransportOptions http) } finally { - session ??= TakeSession(context); - session?.Dispose(); + using var sessionToDispose = session ?? TakeSession(context); ReplMcpHttpDiagnostics.SessionsEnded.Add(1); ReplMcpHttpDiagnostics.SessionsActive.Add(-1); }