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/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/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..5bec0f8 --- /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; + } + + 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..6f76ff5 --- /dev/null +++ b/src/Repl.Mcp.AspNetCore/McpHttpModule.cs @@ -0,0 +1,122 @@ +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.Http.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, + int? idleTimeoutSeconds, + int? maxIdleSessions, + bool quiet, + CancellationToken cancellationToken) + { + var runOptions = CreateRunOptions( + host, + port, + path, + allowRemote, + 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 (InvalidOperationException ex) + { + ReplMcpHttpDiagnostics.StartupFailures.Add(1); + await io.Error.WriteLineAsync(ex.ToString()).ConfigureAwait(false); + return Results.Exit(1); + } + } + + private ReplMcpHttpServerOptions CreateRunOptions( + string? host, + int? port, + string? path, + bool allowRemote, + int? idleTimeoutSeconds, + int? maxIdleSessions, + bool quiet) + { + var runOptions = _options.Clone(); + ApplyEndpointOptions(runOptions, host, port, path); + + runOptions.AllowRemote |= allowRemote; + runOptions.Quiet |= quiet; + + if (idleTimeoutSeconds is { } idleSeconds) + { + runOptions.Http.IdleTimeout = TimeSpan.FromSeconds(idleSeconds); + } + + if (maxIdleSessions is { } maxSessions) + { + runOptions.Http.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/README.md b/src/Repl.Mcp.AspNetCore/README.md new file mode 100644 index 0000000..b0ef54c --- /dev/null +++ b/src/Repl.Mcp.AspNetCore/README.md @@ -0,0 +1,84 @@ +# Repl.Mcp.AspNetCore + +ASP.NET Core Streamable HTTP hosting integration for Repl MCP servers. + +## Hosted in ASP.NET Core + +```csharp +builder.Services.AddReplMcpHttp(replApp); + +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. + +```csharp +builder.Services.AddAuthentication(); +builder.Services.AddAuthorization(); +builder.Services.AddReplMcpHttp(replApp); + +var app = builder.Build(); +app.UseAuthentication(); +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: + +```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. 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/Repl.Mcp.AspNetCore.csproj b/src/Repl.Mcp.AspNetCore/Repl.Mcp.AspNetCore.csproj new file mode 100644 index 0000000..35a7899 --- /dev/null +++ b/src/Repl.Mcp.AspNetCore/Repl.Mcp.AspNetCore.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + ASP.NET Core Streamable HTTP hosting integration for Repl MCP servers. + README.md + + + + + + + + + + + + + + + + + + + + diff --git a/src/Repl.Mcp.AspNetCore/ReplMcpEndpointRouteBuilderExtensions.cs b/src/Repl.Mcp.AspNetCore/ReplMcpEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..6dcc8ad --- /dev/null +++ b/src/Repl.Mcp.AspNetCore/ReplMcpEndpointRouteBuilderExtensions.cs @@ -0,0 +1,32 @@ +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) + { + 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); +} diff --git a/src/Repl.Mcp.AspNetCore/ReplMcpHttpDiagnostics.cs b/src/Repl.Mcp.AspNetCore/ReplMcpHttpDiagnostics.cs new file mode 100644 index 0000000..3a807c4 --- /dev/null +++ b/src/Repl.Mcp.AspNetCore/ReplMcpHttpDiagnostics.cs @@ -0,0 +1,18 @@ +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 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/ReplMcpHttpOptions.cs b/src/Repl.Mcp.AspNetCore/ReplMcpHttpOptions.cs new file mode 100644 index 0000000..3e2f4d9 --- /dev/null +++ b/src/Repl.Mcp.AspNetCore/ReplMcpHttpOptions.cs @@ -0,0 +1,56 @@ +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; } + + 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/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/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..776f2b1 --- /dev/null +++ b/src/Repl.Mcp.AspNetCore/ReplMcpHttpSecurityOptions.cs @@ -0,0 +1,48 @@ +namespace Repl.Mcp.AspNetCore; + +/// +/// Configures defensive HTTP checks for self-hosted Repl MCP endpoints. +/// +public sealed class ReplMcpHttpSecurityOptions +{ + private static readonly string[] DefaultAllowedHosts = + [ + "localhost", + "127.0.0.1", + "::1", + "[::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. + /// + 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; } + + internal bool UsesDefaultAllowedHosts() + { + if (AllowAnyHost || AllowedHosts.Count != DefaultAllowedHosts.Length) + { + return false; + } + + return !DefaultAllowedHosts.Any(defaultHost => !ContainsAllowedHost(defaultHost)); + } + + 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 new file mode 100644 index 0000000..e159dfe --- /dev/null +++ b/src/Repl.Mcp.AspNetCore/ReplMcpHttpServer.cs @@ -0,0 +1,106 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Repl.Mcp.AspNetCore; + +/// +/// Runs self-hosted Repl MCP Streamable HTTP servers. +/// +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. + /// + /// 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.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); + + var webApp = builder.Build(); + await using (webApp.ConfigureAwait(false)) + { + webApp.UseMiddleware(); + options.ConfigureApp?.Invoke(webApp); + var endpoint = webApp.MapReplMcp(binding.Path); + options.ConfigureEndpoint?.Invoke(endpoint); + + 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); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + LogShutdownRequested(webApp.Logger, null); + } + finally + { + await webApp.StopAsync(CancellationToken.None).ConfigureAwait(false); + } + } + } + + 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 new file mode 100644 index 0000000..26c7c67 --- /dev/null +++ b/src/Repl.Mcp.AspNetCore/ReplMcpHttpServerOptions.cs @@ -0,0 +1,136 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; + +namespace Repl.Mcp.AspNetCore; + +/// +/// Configures the self-hosted Repl MCP Streamable HTTP server. +/// +public sealed class ReplMcpHttpServerOptions +{ + /// + /// Default local-only host. + /// + public static readonly string DefaultHost = "127.0.0.1"; + + /// + /// Default HTTP port. The digits correspond to "repl" on a phone keypad. + /// + public static readonly int DefaultPort = 7375; + + /// + /// Default Streamable HTTP endpoint path. + /// + 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. + /// + 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 the shared Repl MCP HTTP transport options. + /// + public ReplMcpHttpOptions Http { get; } = new(); + + /// + /// Gets the self-hosted HTTP security options. + /// + public ReplMcpHttpSecurityOptions Security { get; } = new(); + + /// + /// Gets or sets a callback invoked before the inner is built. + /// + public Action? ConfigureBuilder { get; set; } + + /// + /// Gets or sets a callback invoked after the inner is built and before MCP is mapped. + /// + public Action? ConfigureApp { get; set; } + + /// + /// Gets or sets a callback invoked after the MCP endpoint is mapped. + /// + public Action? ConfigureEndpoint { 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); + } + + private static void CopyHttpOptions(ReplMcpHttpOptions source, ReplMcpHttpOptions target) + { + 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) + { + 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 new file mode 100644 index 0000000..2633829 --- /dev/null +++ b/src/Repl.Mcp.AspNetCore/ReplMcpHttpServiceCollectionExtensions.cs @@ -0,0 +1,169 @@ +using Microsoft.AspNetCore.Http; +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 +{ + private static readonly object SessionItemKey = new(); + + /// + /// 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) + { + 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); + + var builder = services.AddMcpServer(); + if (options.EnableAuthorizationFilters) + { + builder.AddAuthorizationFilters(); + } + + 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); + } + + 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 + { + 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; + } + }; + } + + 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 + { + using var sessionToDispose = session ?? TakeSession(context); + ReplMcpHttpDiagnostics.SessionsEnded.Add(1); + ReplMcpHttpDiagnostics.SessionsActive.Add(-1); + } + }; +#pragma warning restore MCPEXP002 + } + + 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; + } + + 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.Mcp/McpReplExtensions.cs b/src/Repl.Mcp/McpReplExtensions.cs index e05a31c..2c0a479 100644 --- a/src/Repl.Mcp/McpReplExtensions.cs +++ b/src/Repl.Mcp/McpReplExtensions.cs @@ -64,6 +64,41 @@ 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); + } + + /// + /// 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 @@ -89,6 +124,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; + } +} 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/Given_McpHttpSelfHostSecurity.cs b/src/Repl.McpTests/Given_McpHttpSelfHostSecurity.cs new file mode 100644 index 0000000..6f91eef --- /dev/null +++ b/src/Repl.McpTests/Given_McpHttpSelfHostSecurity.cs @@ -0,0 +1,215 @@ +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; + +[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); + } + + [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) => + 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; + } +} 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 @@ + 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 @@ +