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() {