Skip to content
Open
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
8 changes: 7 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 6 additions & 2 deletions DecompilerServer.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.8" />
<PackageReference Include="ModelContextProtocol" Version="1.3.0" />
<PackageReference Include="ModelContextProtocol" Version="2.2.0" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="2.2.0" />
<PackageReference Include="ICSharpCode.Decompiler" Version="10.0.1.8346" />
</ItemGroup>

<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

<ItemGroup>
<Compile Remove="EmbeddedSourceTestLibrary/**" />
<Compile Remove="NestedNoSymbolsTestLibrary/**" />
Expand Down
132 changes: 109 additions & 23 deletions Program.cs
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<WorkspaceBootstrapService>();

// Register DecompilerServer services as singletons for state persistence
builder.Services.AddSingleton<DecompilerWorkspace>();
builder.Services.AddSingleton<AssemblyContextManager>();
builder.Services.AddSingleton<MemberResolver>();
builder.Services.AddSingleton<DecompilerService>();
builder.Services.AddSingleton<UsageAnalyzer>();
builder.Services.AddSingleton<InheritanceAnalyzer>();
builder.Services.AddSingleton<ResponseFormatter>();
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<WorkspaceBootstrapService>();
services.AddSingleton<DecompilerWorkspace>();
services.AddSingleton<AssemblyContextManager>();
services.AddSingleton<MemberResolver>();
services.AddSingleton<DecompilerService>();
services.AddSingleton<UsageAnalyzer>();
services.AddSingleton<InheritanceAnalyzer>();
services.AddSingleton<ResponseFormatter>();
}

internal sealed record LaunchOptions(bool UseHttp, string HttpUrl, string[] HostArguments);
}
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

</details>

<details>
Expand Down
45 changes: 45 additions & 0 deletions Tests/McpProtocolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,51 @@ namespace Tests;
/// </summary>
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<ArgumentException>(() =>
DecompilerServer.Program.ParseLaunchOptions([], "http", httpUrl));
}

[Fact]
public void McpServer_Should_Expose_Concise_ServerInstructions()
{
Expand Down