Skip to content

Add MCP Streamable HTTP hosting#27

Draft
carldebilly wants to merge 9 commits into
mainfrom
feature/mcp-httpserve
Draft

Add MCP Streamable HTTP hosting#27
carldebilly wants to merge 9 commits into
mainfrom
feature/mcp-httpserve

Conversation

@carldebilly

Copy link
Copy Markdown
Member

Summary

Adds ASP.NET Core Streamable HTTP hosting support for Repl MCP servers, plus a self-hosted CLI command for simple local/trusted-network exposure.

This introduces:

  • Repl.Mcp.AspNetCore
  • hosted APIs:
    • services.AddReplMcpHttp(replApp)
    • app.MapReplMcp("/mcp")
  • self-hosted CLI command:
    • myapp mcp httpserve
    • aliases: mcp http, mcp http-serve
  • default local endpoint:
    • http://127.0.0.1:7375/mcp

Security Model

The self-hosted CLI path is intended for local agents and trusted development/container networks.

Defaults:

  • binds to loopback by default
  • rejects non-loopback bindings unless --allow-remote is explicitly set
  • filters loopback Host headers
  • rejects unexpected browser Origin headers
  • allows non-browser MCP clients that do not send Origin

For production-grade authentication, TLS, reverse proxy integration, CORS policy, or more rigorous Kestrel configuration, apps should use the hosted ASP.NET Core integration and compose normal ASP.NET Core middleware/endpoint conventions.

Session Model

HTTP MCP sessions are stateful by default and support parallel clients.

This PR also adds deterministic session cleanup through the SDK RunSessionHandler hook, so per-session REPL/MCP resources are disposed when the MCP session completes.

Self-hosted defaults are conservative:

  • idle timeout: 30 minutes
  • max idle sessions: 100

Notes

The CLI intentionally does not expose a --stateless switch in this first slice. Stateless and deeper transport knobs remain available through hosted/advanced configuration.

Monitoring/TUI status surfaces are also left for a later slice.

Tests

Validated locally:

dotnet test src\Repl.McpTests\Repl.McpTests.csproj

Result:

Passed: 183/183

Comment thread src/Repl.Mcp.AspNetCore/ReplMcpHttpSecurityOptions.cs Fixed
Comment thread src/Repl.Mcp.AspNetCore/ReplMcpHttpSecurityOptions.cs Fixed
Comment thread src/Repl.Mcp.AspNetCore/ReplMcpHttpServer.cs Fixed
Comment thread src/Repl.Mcp.AspNetCore/ReplMcpHttpServiceCollectionExtensions.cs Fixed
Comment thread src/Repl.Mcp.AspNetCore/McpHttpModule.cs Fixed
@carldebilly carldebilly marked this pull request as ready for review June 24, 2026 22:03

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9cfacf6537

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

var configureSessionOptions = http.ConfigureSessionOptions;
http.ConfigureSessionOptions = async (context, serverOptions, cancellationToken) =>
{
var sessionServices = new CompositeServiceProvider(context.RequestServices, app.Services);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Do not capture request-scoped services for stateful sessions

ConfigureSessionOptions is invoked only for the initialize request in stateful mode (the default), so this stores that request's scoped RequestServices for the entire MCP session (see the SDK docs for ConfigureSessionOptions: https://csharp.sdk.modelcontextprotocol.io/api/ModelContextProtocol.AspNetCore.HttpServerTransportOptions.html). After the initialize request completes, later tool/resource calls that resolve ASP.NET scoped services such as DbContext or user/request-bound services can resolve from a disposed or stale scope; create a session-owned scope or resolve from the current request/server scope instead of retaining context.RequestServices here.

Useful? React with 👍 / 👎.


if (!options.Quiet && output is not null)
{
await output.WriteLineAsync($"MCP HTTP server listening on {binding.EndpointUrl}").ConfigureAwait(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Announce the listener only after Kestrel starts

When the configured address cannot actually bind, such as when port 7375 is already in use or the process lacks permission, StartAsync throws after this line has already told the caller that the MCP server is listening. That gives users and scripts a false-ready signal for a server that never started; write the endpoint message only after StartAsync succeeds.

Useful? React with 👍 / 👎.

@carldebilly

Copy link
Copy Markdown
Member Author

@autocarl Please do a review of this PR. I want to know if the feature makes sense.

@autocarl autocarl left a comment

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.

Feature direction makes sense: separating ASP.NET Core hosting into Repl.Mcp.AspNetCore and keeping the self-hosted CLI local/trusted-network by default is the right shape. I would fix the inline items before merging, especially the session service lifetime issue.

Validation I ran locally:

  • dotnet build src/Repl.slnx -c Release — passed, 0 warnings/errors
  • dotnet test src/Repl.slnx -c Release --no-build — passed, 992/992

Current CI checks are also green.

Non-blocking nit: the new MSTest methods in Given_McpHttpBinding / Given_McpHttpSelfHostSecurity would be more consistent with the existing MCP tests if they had [Description(...)] attributes.

http.ConfigureSessionOptions = async (context, serverOptions, cancellationToken) =>
{
var sessionServices = new CompositeServiceProvider(context.RequestServices, app.Services);
var session = app.CreateMcpServerSession(sessionServices, options.ConfigureServer);

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.

This captures context.RequestServices into the MCP session provider. In stateful mode, ConfigureSessionOptions is tied to the initialize request, so later tool/resource calls can run after that request scope has been disposed or become stale. Please create a session-owned IServiceScope from IServiceScopeFactory, use that scope in the session CompositeServiceProvider, and dispose it with the ReplMcpServerSession / session cleanup path. A regression test should dispose the initialize request scope before a later session call resolves a scoped service.


if (!options.Quiet && output is not null)
{
await output.WriteLineAsync($"MCP HTTP server listening on {binding.EndpointUrl}").ConfigureAwait(false);

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.

This prints the listener endpoint before webApp.StartAsync(...) succeeds. If Kestrel fails to bind (for example because the port is already in use), users and scripts get a false-ready signal for a server that never started. Move the startup output until after StartAsync completes successfully.


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.


private static void ConfigureRunSessionHandler(HttpServerTransportOptions http)
{
#pragma warning disable MCPEXP002

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.

This experimental SDK hook is central to deterministic session cleanup. The suppression is scoped, but it should carry a short justification comment explaining why RunSessionHandler is required and what SDK behavior this depends on; otherwise a future cleanup could remove the hook and reintroduce session leaks.

@autocarl autocarl left a comment

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.

Additional review notes from a second pass. These are separate from the earlier comments and are intended as targeted follow-ups.

services.TryAddSingleton(app);

var builder = services.AddMcpServer();
if (options.EnableAuthorizationFilters)

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.

This makes authorization filters opt-in for the ASP.NET Core integration. In MCP, AddAuthorizationFilters() is what enforces [Authorize]/policy metadata on tools, prompts, and resources; callers might protect the /mcp endpoint generally but accidentally allow any authenticated caller to invoke primitives intended for stricter roles/policies. Consider registering authorization filters by default with an explicit opt-out, or at least make the production sample/docs enable this option.

/// <summary>
/// Gets or sets the amount of idle time after which a stateful MCP session expires.
/// </summary>
public TimeSpan? IdleTimeout { get; set; }

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.

Leaving these hosted HTTP options nullable means AddReplMcpHttp(replApp) inherits the MCP SDK stateful defaults, which are much larger than the self-hosted path's Repl-specific defaults. Since this integration creates Repl/MCP session state per HTTP session, consider giving hosted options conservative defaults too, or requiring callers to explicitly opt into the SDK limits/stateless mode.

context.Items.Should().NotBeEmpty();

#pragma warning disable MCPEXP002
await transport.RunSessionHandler!(context, null!, CancellationToken.None);

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.

This test stubs RunSessionHandler and passes null!, so it verifies that HttpContext.Items is cleared but not the real Streamable HTTP session lifecycle. It would not catch the request-scope/session-lifetime issue above. Consider adding an integration-style test that initializes a stateful MCP HTTP session, performs a follow-up call on a later request that resolves a scoped ASP.NET service, and then verifies cleanup/disposal.

@carldebilly carldebilly marked this pull request as draft June 26, 2026 00:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants