-
Notifications
You must be signed in to change notification settings - Fork 2
Add MCP Streamable HTTP hosting #27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 8 commits
47d84a4
1c4bbaf
4a6ab40
df7e816
d387c3a
75c4fcc
d5dece1
004775c
9cfacf6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| namespace Repl.Mcp.AspNetCore; | ||
|
|
||
| internal sealed record McpHttpBinding( | ||
| string Host, | ||
| int Port, | ||
| string Path, | ||
| string ListenUrl, | ||
| string EndpointUrl, | ||
| bool AllowsRemote); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<object> 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 (Exception 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The CLI accepts any integer for |
||
| } | ||
|
|
||
| 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; | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| <Project Sdk="Microsoft.NET.Sdk"> | ||
|
|
||
| <PropertyGroup> | ||
| <TargetFramework>net10.0</TargetFramework> | ||
| <Description>ASP.NET Core Streamable HTTP hosting integration for Repl MCP servers.</Description> | ||
| <PackageReadmeFile>README.md</PackageReadmeFile> | ||
| </PropertyGroup> | ||
|
|
||
| <ItemGroup> | ||
| <InternalsVisibleTo Include="Repl.McpTests" /> | ||
| </ItemGroup> | ||
|
|
||
| <ItemGroup> | ||
| <ProjectReference Include="..\Repl.Mcp\Repl.Mcp.csproj" /> | ||
| </ItemGroup> | ||
|
|
||
| <ItemGroup> | ||
| <FrameworkReference Include="Microsoft.AspNetCore.App" /> | ||
| <PackageReference Include="ModelContextProtocol.AspNetCore" /> | ||
| </ItemGroup> | ||
|
|
||
| <ItemGroup> | ||
| <None Include="README.md" Pack="true" PackagePath="" /> | ||
| </ItemGroup> | ||
|
|
||
| </Project> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| using Microsoft.AspNetCore.Builder; | ||
| using Microsoft.AspNetCore.Routing; | ||
|
|
||
| namespace Repl.Mcp.AspNetCore; | ||
|
|
||
| /// <summary> | ||
| /// Extension methods for mapping Repl MCP endpoints. | ||
| /// </summary> | ||
| public static class ReplMcpEndpointRouteBuilderExtensions | ||
| { | ||
| /// <summary> | ||
| /// Maps the Repl MCP Streamable HTTP endpoint. | ||
| /// </summary> | ||
| /// <param name="endpoints">Endpoint route builder.</param> | ||
| /// <param name="pattern">Route pattern for the Streamable HTTP endpoint.</param> | ||
| /// <returns>An endpoint convention builder.</returns> | ||
| public static IEndpointConventionBuilder MapReplMcp( | ||
| this IEndpointRouteBuilder endpoints, | ||
| string pattern) | ||
| { | ||
| ArgumentNullException.ThrowIfNull(endpoints); | ||
| return endpoints.MapMcp(pattern); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Maps the Repl MCP Streamable HTTP endpoint at the default path. | ||
| /// </summary> | ||
| /// <param name="endpoints">Endpoint route builder.</param> | ||
| /// <returns>An endpoint convention builder.</returns> | ||
| public static IEndpointConventionBuilder MapReplMcp(this IEndpointRouteBuilder endpoints) => | ||
| endpoints.MapReplMcp(ReplMcpHttpServerOptions.DefaultPath); | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.