From a72401af667a4b197dba75626f42ac9ed43a3e9f Mon Sep 17 00:00:00 2001 From: TheVillageGuy <47496248+TheVillageGuy@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:56:00 +0200 Subject: [PATCH] feat: add shared loopback HTTP transport Add an opt-in Streamable HTTP host so multiple MCP clients can use one DecompilerServer process and singleton workspace instead of retaining one stdio child per client. Keep stdio as the compatibility default, restrict HTTP listeners to configurable loopback origins, and bound compatibility sessions to a ten-minute idle timeout and 64 sessions. Upgrade the MCP SDK to 2.2.0 with ASP.NET Core transport support, document Codex URL configuration and the runtime model, and cover launch parsing plus loopback validation. Validation: nine new launch tests pass; stdio initialize/tools-list smoke passes; two independent HTTP sessions share one PID; an idle HTTP server used 63.5 MiB and 0.125 CPU seconds over five seconds. The broader suite passes 195/201, with six unchanged Windows path-normalization assertions failing outside this change. --- ARCHITECTURE.md | 8 ++- DecompilerServer.csproj | 8 ++- Program.cs | 132 +++++++++++++++++++++++++++++++------- README.md | 20 ++++++ Tests/McpProtocolTests.cs | 45 +++++++++++++ 5 files changed, 187 insertions(+), 26 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d4a8cc6..8ff5255 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -10,7 +10,13 @@ Documentation policy: ## Runtime Model -`Program.cs` builds a hosted stdio MCP server and auto-discovers tools from the assembly. +`Program.cs` builds an MCP server and auto-discovers tools from the assembly. + +Transport modes: +- stdio remains the default and owns one process per MCP client; +- `--http` (or `DECOMPILER_TRANSPORT=http`) hosts one shared Streamable HTTP endpoint at `/mcp`; +- HTTP mode binds only to a validated loopback origin, defaults to `http://127.0.0.1:30503`, and accepts a different origin through `DECOMPILER_HTTP_URL`; +- HTTP uses stateless requests for current clients and bounded, ten-minute sessions for initialize-handshake clients, while all clients share the singleton `DecompilerWorkspace`. Key startup behavior: - registers `DecompilerWorkspace` plus the legacy singleton services; diff --git a/DecompilerServer.csproj b/DecompilerServer.csproj index c7eff3b..659d734 100644 --- a/DecompilerServer.csproj +++ b/DecompilerServer.csproj @@ -9,11 +9,15 @@ - - + + + + + + diff --git a/Program.cs b/Program.cs index 8d7d17e..8d54b76 100644 --- a/Program.cs +++ b/Program.cs @@ -1,13 +1,19 @@ -using Microsoft.Extensions.DependencyInjection; +using System.Net; +using DecompilerServer.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using DecompilerServer.Services; +using ModelContextProtocol.AspNetCore; using ModelContextProtocol.Server; namespace DecompilerServer; public partial class Program { + internal const string DefaultHttpUrl = "http://127.0.0.1:30503"; + internal const string ServerInstructions = """ DecompilerServer inspects loaded .NET assemblies. Use search_symbols first for fragments; use resolve_member_id first for fully-qualified or XML-doc-like guessed symbols. Common parameter names: search_types/search_members use query, not pattern; resolve_member_id/get_decompiled_source/find_usages use memberId; find_callers/find_callees/get_overrides use methodId; get_types_in_namespace uses ns. @@ -22,33 +28,113 @@ internal static void ConfigureMcpServerOptions(McpServerOptions options) public static async Task Main(string[] args) { - var builder = Host.CreateApplicationBuilder(args); - builder.Logging.ClearProviders(); - builder.Logging.AddProvider(new StderrLoggerProvider()); - builder.Logging.SetMinimumLevel(LogLevel.Information); - // builder.Logging.AddFilter("Microsoft.Hosting.Lifetime", LogLevel.Warning); - // builder.Logging.AddFilter("ModelContextProtocol", LogLevel.Warning); - builder.Services.AddHostedService(); - - // Register DecompilerServer services as singletons for state persistence - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + var launchOptions = ParseLaunchOptions( + args, + Environment.GetEnvironmentVariable("DECOMPILER_TRANSPORT"), + Environment.GetEnvironmentVariable("DECOMPILER_HTTP_URL")); + + if (launchOptions.UseHttp) + { + await RunHttpAsync(launchOptions); + return; + } + + await RunStdioAsync(launchOptions.HostArguments); + } + + internal static LaunchOptions ParseLaunchOptions( + string[] args, + string? transportEnvironment, + string? httpUrlEnvironment) + { + var useHttpArgument = args.Any(arg => string.Equals(arg, "--http", StringComparison.OrdinalIgnoreCase)); + var hostArguments = args + .Where(arg => !string.Equals(arg, "--http", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + var useHttp = useHttpArgument || string.Equals(transportEnvironment, "http", StringComparison.OrdinalIgnoreCase); + var httpUrl = string.IsNullOrWhiteSpace(httpUrlEnvironment) ? DefaultHttpUrl : httpUrlEnvironment.Trim(); + + if (useHttp) + ValidateLoopbackHttpUrl(httpUrl); + + return new LaunchOptions(useHttp, httpUrl, hostArguments); + } + internal static void ValidateLoopbackHttpUrl(string httpUrl) + { + if (!Uri.TryCreate(httpUrl, UriKind.Absolute, out var uri) + || !string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) + || (!string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase) + && (!IPAddress.TryParse(uri.Host, out var address) || !IPAddress.IsLoopback(address))) + || uri.AbsolutePath != "/" + || !string.IsNullOrEmpty(uri.Query) + || !string.IsNullOrEmpty(uri.Fragment) + || !string.IsNullOrEmpty(uri.UserInfo)) + { + throw new ArgumentException( + "DECOMPILER_HTTP_URL must be an HTTP loopback origin such as http://127.0.0.1:30503.", + nameof(httpUrl)); + } + } + + private static async Task RunStdioAsync(string[] args) + { + var builder = Host.CreateApplicationBuilder(args); + ConfigureLogging(builder.Logging); + AddDecompilerServices(builder.Services); builder.Services - .AddMcpServer(ConfigureMcpServerOptions) // core MCP server services - .WithStdioServerTransport() // Codex talks to STDIO servers - .WithToolsFromAssembly(); // auto-discover [McpServerTool]s in this assembly + .AddMcpServer(ConfigureMcpServerOptions) + .WithStdioServerTransport() + .WithToolsFromAssembly(); var app = builder.Build(); - - // Initialize service locator ServiceLocator.SetServiceProvider(app.Services); + await app.RunAsync(); + } + private static async Task RunHttpAsync(LaunchOptions launchOptions) + { + var builder = WebApplication.CreateBuilder(launchOptions.HostArguments); + ConfigureLogging(builder.Logging); + builder.WebHost.UseUrls(launchOptions.HttpUrl); + builder.Configuration["AllowedHosts"] = "localhost;127.0.0.1;[::1]"; + AddDecompilerServices(builder.Services); + builder.Services + .AddMcpServer(ConfigureMcpServerOptions) + .WithHttpTransport(options => + { + options.SessionMode = HttpServerSessionMode.StatefulForInitializeClients; +#pragma warning disable MCP9006 + options.IdleTimeout = TimeSpan.FromMinutes(10); + options.MaxIdleSessionCount = 64; +#pragma warning restore MCP9006 + }) + .WithToolsFromAssembly(); + + var app = builder.Build(); + app.MapMcp("/mcp"); + ServiceLocator.SetServiceProvider(app.Services); await app.RunAsync(); } + + private static void ConfigureLogging(ILoggingBuilder logging) + { + logging.ClearProviders(); + logging.AddProvider(new StderrLoggerProvider()); + logging.SetMinimumLevel(LogLevel.Information); + } + + private static void AddDecompilerServices(IServiceCollection services) + { + services.AddHostedService(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + } + + internal sealed record LaunchOptions(bool UseHttp, string HttpUrl, string[] HostArguments); } diff --git a/README.md b/README.md index bddadf9..c683c0b 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,26 @@ Open Cursor Settings → MCP and add a new server, or edit `.cursor/mcp.json` in On Windows, use the `.exe` extension and escaped backslashes. +When many Codex tasks use the same project, stdio starts one DecompilerServer +child process per task. To share one bounded workspace and one process instead, +start DecompilerServer once in loopback HTTP mode: + +```text +DecompilerServer.exe --http +``` + +Then configure Codex to connect to that shared process: + +```toml +[mcp_servers.decompiler] +url = "http://127.0.0.1:30503/mcp" +``` + +The default listener is `http://127.0.0.1:30503`. Set +`DECOMPILER_HTTP_URL` before starting the server to choose another HTTP +loopback origin. Non-loopback and HTTPS bindings are rejected; terminate TLS +at a separately secured reverse proxy if remote access is required. +
diff --git a/Tests/McpProtocolTests.cs b/Tests/McpProtocolTests.cs index 6042dc8..6646a97 100644 --- a/Tests/McpProtocolTests.cs +++ b/Tests/McpProtocolTests.cs @@ -13,6 +13,51 @@ namespace Tests; /// public class McpProtocolTests { + [Fact] + public void LaunchOptions_DefaultToStdio() + { + var options = DecompilerServer.Program.ParseLaunchOptions([], null, null); + + Assert.False(options.UseHttp); + Assert.Equal(DecompilerServer.Program.DefaultHttpUrl, options.HttpUrl); + Assert.Empty(options.HostArguments); + } + + [Fact] + public void LaunchOptions_EnableHttpWithoutPassingPrivateSwitchToHost() + { + var options = DecompilerServer.Program.ParseLaunchOptions( + ["--http", "--environment", "Development"], + null, + null); + + Assert.True(options.UseHttp); + Assert.Equal(["--environment", "Development"], options.HostArguments); + } + + [Theory] + [InlineData("http://localhost:30503")] + [InlineData("http://127.0.0.1:30503")] + [InlineData("http://[::1]:30503")] + public void LaunchOptions_AcceptLoopbackHttpOrigins(string httpUrl) + { + var options = DecompilerServer.Program.ParseLaunchOptions([], "http", httpUrl); + + Assert.True(options.UseHttp); + Assert.Equal(httpUrl, options.HttpUrl); + } + + [Theory] + [InlineData("https://127.0.0.1:30503")] + [InlineData("http://0.0.0.0:30503")] + [InlineData("http://example.com:30503")] + [InlineData("http://127.0.0.1:30503/mcp")] + public void LaunchOptions_RejectNonLoopbackOrNonOriginUrls(string httpUrl) + { + Assert.Throws(() => + DecompilerServer.Program.ParseLaunchOptions([], "http", httpUrl)); + } + [Fact] public void McpServer_Should_Expose_Concise_ServerInstructions() {