Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5"/>
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.5"/>
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.5"/>
<PackageVersion Include="ModelContextProtocol" Version="1.2.0"/>
<PackageVersion Include="ModelContextProtocol" Version="1.4.0"/>
<PackageVersion Include="ModelContextProtocol.AspNetCore" Version="1.4.0"/>
<PackageVersion Include="Spectre.Console" Version="0.55.0"/>
</ItemGroup>

Expand Down
9 changes: 9 additions & 0 deletions src/Repl.Mcp.AspNetCore/CompositeServiceProvider.cs
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);
}
9 changes: 9 additions & 0 deletions src/Repl.Mcp.AspNetCore/McpHttpBinding.cs
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);
89 changes: 89 additions & 0 deletions src/Repl.Mcp.AspNetCore/McpHttpBindingFactory.cs
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;
}
122 changes: 122 additions & 0 deletions src/Repl.Mcp.AspNetCore/McpHttpModule.cs
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 (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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The CLI accepts any integer for --idle-timeout-seconds and --max-idle-sessions and passes those values directly to the transport. Negative or zero values can produce unclear runtime behavior or defer validation to lower layers. Validate these command options explicitly (for example > 0) and return a clear CLI error before starting the server.

}

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;
}
}
}
84 changes: 84 additions & 0 deletions src/Repl.Mcp.AspNetCore/README.md
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`.
26 changes: 26 additions & 0 deletions src/Repl.Mcp.AspNetCore/Repl.Mcp.AspNetCore.csproj
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>
32 changes: 32 additions & 0 deletions src/Repl.Mcp.AspNetCore/ReplMcpEndpointRouteBuilderExtensions.cs
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);
}
Loading
Loading