diff --git a/.gitignore b/.gitignore
index e7350e4..8fad305 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,6 +27,11 @@ coverage.html
# Claude Code local settings
.claude/
+# Roslyn helper artifacts
+tools/roslyn-helper/**/bin/
+tools/roslyn-helper/**/obj/
+tools/roslyn-helper/publish/
+
# OS artifacts
.DS_Store
Thumbs.db
diff --git a/tools/roslyn-helper/.gitignore b/tools/roslyn-helper/.gitignore
new file mode 100644
index 0000000..4d4cd45
--- /dev/null
+++ b/tools/roslyn-helper/.gitignore
@@ -0,0 +1,4 @@
+bin/
+obj/
+publish/
+*.user
diff --git a/tools/roslyn-helper/README.md b/tools/roslyn-helper/README.md
new file mode 100644
index 0000000..516718a
--- /dev/null
+++ b/tools/roslyn-helper/README.md
@@ -0,0 +1,62 @@
+# ctx-roslyn-helper
+
+C# subprocess invoked by `ctx csharp` commands. Loads .NET solutions with
+Roslyn and answers JSON-RPC queries over stdin/stdout.
+
+## Protocol
+
+One JSON object per line, UTF-8 without BOM.
+
+**Request:** `{"id": 1, "method": "ping", "params": {}}`
+**Success:** `{"id": 1, "result": {...}}`
+**Error:** `{"id": 1, "error": {"code": "E_NOT_FOUND", "message": "..."}}`
+
+### Methods
+
+| Method | Params | Description |
+|------------------|---------------------------|------------------------------------|
+| `ping` | `{}` | Health check, returns version |
+| `loadSolution` | `{"path": "C:\\...\\x.sln"}` | Load solution into workspace |
+| `projectSummary` | `{}` | Summary of loaded solution |
+| `shutdown` | `{}` | Exit cleanly (no response written) |
+
+## Build
+
+Requires: .NET 8+ SDK.
+
+```sh
+dotnet build src/RoslynHelper -c Release
+```
+
+## Publish
+
+```sh
+dotnet publish src/RoslynHelper -c Release -r win-x64 --self-contained false -o publish/
+```
+
+Output: `publish/ctx-roslyn-helper.exe`
+
+## Manual test
+
+```sh
+cd publish
+./ctx-roslyn-helper.exe
+```
+
+Then type line by line:
+
+```
+{"id": 1, "method": "ping", "params": {}}
+{"id": 2, "method": "loadSolution", "params": {"path": "C:\\path\\to\\My.sln"}}
+{"id": 3, "method": "projectSummary", "params": {}}
+{"id": 99, "method": "shutdown", "params": {}}
+```
+
+## Notes
+
+- Does NOT need to be self-contained. Requires .NET 8+ runtime on the target machine.
+ Users of `ctx csharp` are .NET developers — the runtime is already there.
+- MSBuild warnings logged to stderr are informational (missing SDK targets, etc.).
+ Only `WorkspaceFailed` events with `Failure` kind indicate real problems.
+- The helper is spawned once per `ctx` invocation and kept alive for all queries
+ in that session. ctx Go manages the subprocess lifecycle.
diff --git a/tools/roslyn-helper/RoslynHelper.sln b/tools/roslyn-helper/RoslynHelper.sln
new file mode 100644
index 0000000..c358667
--- /dev/null
+++ b/tools/roslyn-helper/RoslynHelper.sln
@@ -0,0 +1,39 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RoslynHelper", "src\RoslynHelper\RoslynHelper.csproj", "{CE832F0D-696C-4830-977F-D694DDCBA532}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|x64.Build.0 = Debug|Any CPU
+ {CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {CE832F0D-696C-4830-977F-D694DDCBA532}.Debug|x86.Build.0 = Debug|Any CPU
+ {CE832F0D-696C-4830-977F-D694DDCBA532}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {CE832F0D-696C-4830-977F-D694DDCBA532}.Release|Any CPU.Build.0 = Release|Any CPU
+ {CE832F0D-696C-4830-977F-D694DDCBA532}.Release|x64.ActiveCfg = Release|Any CPU
+ {CE832F0D-696C-4830-977F-D694DDCBA532}.Release|x64.Build.0 = Release|Any CPU
+ {CE832F0D-696C-4830-977F-D694DDCBA532}.Release|x86.ActiveCfg = Release|Any CPU
+ {CE832F0D-696C-4830-977F-D694DDCBA532}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {CE832F0D-696C-4830-977F-D694DDCBA532} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ EndGlobalSection
+EndGlobal
diff --git a/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Dispatcher.cs b/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Dispatcher.cs
new file mode 100644
index 0000000..aa410cf
--- /dev/null
+++ b/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Dispatcher.cs
@@ -0,0 +1,95 @@
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using RoslynHelper.JsonRpc.Handlers;
+using RoslynHelper.Workspace;
+
+namespace RoslynHelper.JsonRpc;
+
+///
+/// Reads newline-delimited JSON requests from stdin, dispatches to registered
+/// handlers, and writes responses to stdout. One request per line, one response
+/// per line, UTF-8 without BOM.
+///
+public sealed class Dispatcher
+{
+ private static readonly JsonSerializerOptions JsonOpts = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ WriteIndented = false,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ // Allow literal single quotes and em dashes; Go's json.Unmarshal handles them fine.
+ Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
+ };
+
+ private readonly Dictionary _handlers;
+
+ public Dispatcher(WorkspaceManager workspace)
+ {
+ _handlers = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["ping"] = new PingHandler(),
+ ["loadSolution"] = new LoadSolutionHandler(workspace),
+ ["projectSummary"] = new ProjectSummaryHandler(workspace),
+ };
+ }
+
+ public async Task RunAsync(TextReader stdin, TextWriter stdout)
+ {
+ string? line;
+ while ((line = await stdin.ReadLineAsync()) is not null)
+ {
+ if (string.IsNullOrWhiteSpace(line)) continue;
+
+ var response = await DispatchAsync(line);
+ if (response is null) return; // shutdown
+
+ await stdout.WriteLineAsync(response);
+ await stdout.FlushAsync();
+ }
+ }
+
+ private async Task DispatchAsync(string line)
+ {
+ Request? req;
+ try
+ {
+ req = JsonSerializer.Deserialize(line, JsonOpts);
+ if (req is null || string.IsNullOrEmpty(req.Method))
+ return Err(0, "E_INVALID_REQUEST", "request must have id and method");
+ }
+ catch (JsonException ex)
+ {
+ return Err(0, "E_INVALID_REQUEST", $"invalid JSON: {ex.Message}");
+ }
+
+ if (req.Method.Equals("shutdown", StringComparison.OrdinalIgnoreCase))
+ return null; // signal caller to exit
+
+ if (!_handlers.TryGetValue(req.Method, out var handler))
+ return Err(req.Id, "E_UNKNOWN_METHOD", $"method '{req.Method}' not found");
+
+ try
+ {
+ var result = await handler.HandleAsync(req.Params);
+ return Ok(req.Id, result);
+ }
+ catch (KnownException kex)
+ {
+ return Err(req.Id, kex.Code, kex.Message);
+ }
+ catch (Exception ex)
+ {
+ return ErrWithData(req.Id, "E_INTERNAL", ex.Message, new { stackTrace = ex.StackTrace });
+ }
+ }
+
+ private string Ok(int id, object result) =>
+ JsonSerializer.Serialize(new { id, result }, JsonOpts);
+
+ private string Err(int id, string code, string message) =>
+ JsonSerializer.Serialize(new { id, error = new { code, message } }, JsonOpts);
+
+ private string ErrWithData(int id, string code, string message, object data) =>
+ JsonSerializer.Serialize(new { id, error = new { code, message, data } }, JsonOpts);
+}
diff --git a/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Handlers/IHandler.cs b/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Handlers/IHandler.cs
new file mode 100644
index 0000000..83a63fb
--- /dev/null
+++ b/tools/roslyn-helper/src/RoslynHelper/JsonRpc/Handlers/IHandler.cs
@@ -0,0 +1,10 @@
+using System.Text.Json;
+
+namespace RoslynHelper.JsonRpc.Handlers;
+
+/// Contract for a JSON-RPC method handler.
+public interface IHandler
+{
+ /// Execute the handler and return the result object (serialized as the 'result' field).
+ Task