Status: Design Issue: #85 Priority: Post-MVP (v0.9.0 or v1.0.0)
Each MCP client (agent, subagent) spawns its own RoslynMcp process. Each process loads its own MSBuild workspace independently:
- ~100MB+ RAM per Roslyn workspace
- ~10s cold start per process
- Concurrent MSBuild loads contend on disk/lock files
- Three parallel subagents = three redundant copies of the same compilation
This doesn't scale. A developer with Claude Code spawning background agents on a large solution will hit resource exhaustion quickly.
Agent A (stdio) ──┐
│
Agent B (stdio) ──┼── Named Pipe ── Workspace Service (one per solution)
│ ├── MSBuild workspace (loaded once)
Agent C (stdio) ──┘ ├── Compilation cache
├── Pagination cache
LogViewer ────────────────────────── ├── Log event stream
└── FileSystemWatcher
MCP Adapter (thin): Each agent still spawns a process via stdio — that's what MCP requires. But the process is a lightweight adapter that:
- Receives MCP JSON-RPC on stdin
- Forwards tool calls to the Workspace Service via named pipe
- Returns responses on stdout
- Minimal memory footprint (~10MB)
Workspace Service (heavy): A single long-lived process per solution that:
- Owns the MSBuild workspace, compilation cache, pagination cache
- Handles all Roslyn operations (find references, get member body, etc.)
- Manages the FileSystemWatcher
- Serializes write operations, allows concurrent reads
- Streams log events to connected listeners (LogViewer, MCP adapters)
Pipe name: roslynmcp-{hash} where {hash} is the first 8 chars of SHA256 of the canonical solution/project path. This naturally groups instances by solution — two agents working on the same .slnx share the same service.
Why named pipes over HTTP:
- No port management, no firewall prompts, no "is port taken?" conflicts
- No TCP overhead — named pipes are kernel-level IPC, faster for local communication
- Cross-platform via
System.IO.Pipeson .NET - Pipe name encodes the solution identity — no discovery protocol needed
- Simpler security model — filesystem ACLs control access
Why not shared memory / memory-mapped files:
- Roslyn objects aren't designed for shared-memory access
- Serialization boundary is unavoidable — named pipes make it explicit
- Shared memory would require custom allocators and synchronization primitives
Startup:
- MCP adapter starts, computes pipe name from project path
- Tries to connect to existing pipe
- If no service running: acquires a mutex (
Global\roslynmcp-{hash}), spawns itself with--serviceflag - Service loads workspace, opens named pipe server, releases mutex
- Adapter connects, forwards first tool call
Steady state:
- Adapter forwards tool calls over pipe, receives responses
- Service handles calls with
ReaderWriterLockSlim(concurrent reads, serialized writes) - Multiple adapters share the same pipe (NamedPipeServerStream supports multiple connections)
Shutdown:
- When the last adapter disconnects, service starts a grace period (30s)
- If no new connections during grace period, service exits cleanly
- If service crashes, next adapter tool call detects dead pipe and respawns
- Agent sees one slow call (service restart + workspace reload), then fast calls resume
Group death: When the user closes their IDE or kills all agents, all adapters disconnect, grace period expires, service exits. No orphan processes.
Roslyn's MSBuildWorkspace is not thread-safe for mutations. Current WorkspaceInstance already uses ReaderWriterLockSlim:
| Operation | Lock | Concurrent? |
|---|---|---|
| GetSolution, GetCompilation, GetProject | Read | Yes |
| find_references, get_member_body, etc. | Read | Yes |
| InvalidateFile, ReloadIfNeeded | Write | Serialized |
| preview_rename, change_signature (read) | Read | Yes |
| apply_rename, apply_signature_change (write) | Write | Serialized |
| replace_in_code, replace_in_file (write) | Write | Serialized |
This model translates directly to the service. Read-heavy agent workloads (navigation, discovery) scale well. Write-heavy workloads (rapid edits) serialize — but that's already the case today.
Simple request/response over the named pipe. Each message is a length-prefixed JSON blob:
[4 bytes: message length (int32 via BinaryWriter, default little-endian)]
[N bytes: UTF-8 JSON payload]
Endianness is not a concern — this is local-only IPC on the same machine, same binary. BinaryReader/BinaryWriter with default little-endian is sufficient. No need for network byte order or BinaryPrimitives.
Request:
{
"id": "unique-request-id",
"tool": "roslyn_get_member_body",
"args": { "symbolName": "GetCompilation", "projectPath": "..." }
}Response:
{
"id": "unique-request-id",
"result": { ... },
"elapsed_ms": 42,
"estimated_tokens": 350
}No need for JSON-RPC framing — this is internal IPC, not a public protocol. Keep it minimal.
- Parallel subagents share one workspace — the #85 problem, solved
- LogViewer connects to the same service — real-time log streaming without polling a file
- Future HTTP transport (#13) — the service already speaks request/response; adding an HTTP listener is incremental
- Pre-warmed workspaces — service can stay alive between sessions if configured
- Cross-agent coordination — service knows which files are being edited by which agent (future: conflict detection)
- Phase 0 (current): Each MCP process owns its workspace. Ship MVP this way.
- Phase 1: Extract tool logic into a
WorkspaceServiceclass (in-process refactor, no IPC yet). All tools call the service instead of accessing workspace directly. - Phase 2: Add named pipe server to
WorkspaceService. MCP adapter detects service and forwards calls. Falls back to in-process if service unavailable (backwards compatible). - Phase 3: LogViewer connects to service pipe instead of tailing log file.
Each phase ships independently. Phase 1 is pure refactoring with no user-visible change — good for a PR. Phase 2 is the big win. Phase 3 is polish.
Optional copy-on-write semantics for workspace access:
Main Agent (read-write) ── owns the mutable workspace, exclusive writes
Sub-Agent A (read-only) ──┐
Sub-Agent B (read-only) ──┼── share an immutable snapshot, full parallel reads
Sub-Agent C (read-only) ──┘
How it works:
- Each adapter registers with the service as
read-onlyorread-write(viaroslyn_configure(mode: "read-only")tool call orROSLYNMCP_MODE=readonlyenv var) - At most one read-write connection per solution — the service enforces this
- Read-only adapters share the same immutable
Solutionsnapshot — no locks needed, fully parallel - Write attempts from read-only adapters return an actionable error:
"Workspace is read-only. Return your proposed edits to the orchestrating agent." - The agent knows what to do with that error — it becomes a natural producer/consumer pattern
Connection tracking:
- Service tracks each adapter's PID, connection time, and mode
- Stale connection detection: if a PID dies without clean disconnect (poll
Process.GetProcessById), reclaim the slot - When the read-write adapter disconnects: read-only adapters continue working; no automatic promotion (too risky). A new read-write connection can be established by the next agent that requests it.
- PID tracking also enables future "which agent is editing which file" conflict detection
Resource cost: Modest. Read-only adapters skip FSW, skip compilation cache invalidation, and could share a frozen Compilation reference via the service (no serialization overhead for reads).
Subagent behavioral contract: Read-only subagents must understand these constraints. The orchestrating agent should include this in subagent prompts:
You are running in READ-ONLY workspace mode:
- You can read, search, navigate, and analyze code freely.
- You CANNOT write, edit, rename, or apply changes — those calls will be rejected.
- You will NOT see edits made by the main agent until your session ends.
- There are no workspace reloads in this mode — you work on a frozen snapshot.
- When your task is complete, return your findings/proposed edits and terminate.
The orchestrating agent handles all writes.
This is a natural fit for fan-out patterns: main agent dispatches "find all references to X", "get the body of Y", "check if Z implements W" to parallel subagents, collects results, then performs edits itself.
The shared service is an optimization, not a requirement. Users can always run standalone.
Default behavior: Standalone mode — identical to today. One process, one workspace, stdio. Zero IPC, zero surprises. This remains the default through v1.0 at minimum.
Opt-in: ROSLYNMCP_SHARED=true (env var) or --shared (CLI flag) enables the shared service. Without it, RoslynMcp behaves exactly as it does now.
Per-model / per-task configuration: Users may want different modes for different use cases. MCP client configs already support multiple server entries with different env vars:
{
"servers": {
"roslyn-main": {
"command": "RoslynMcp.exe",
"env": { "ROSLYNMCP_MODE": "read-write", "ROSLYNMCP_SHARED": "true" }
},
"roslyn-scout": {
"command": "RoslynMcp.exe",
"env": { "ROSLYNMCP_MODE": "read-only", "ROSLYNMCP_SHARED": "true" }
},
"roslyn-standalone": {
"command": "RoslynMcp.exe"
}
}
}Use cases:
- Cheap model for exploration: standalone + adhoc mode — fast startup, low memory, no shared service overhead
- Expensive model for editing: shared service + read-write — full MSBuild semantics, exclusive writes
- Parallel subagents for research: shared service + read-only — fan-out reads on frozen snapshot
Graduation path: Off by default → opt-in for early adopters → on by default when stable.
- Should the service support multiple solutions simultaneously, or strictly one per process?
- How do we handle
roslyn_respawn— restart just the adapter, or the entire service? - Should adapters cache read-only results locally to reduce pipe round-trips?
- Windows named pipes vs Unix domain sockets — use
System.IO.Pipesabstraction or go lower? - What's the right grace period before service shutdown? Configurable via env var?
- How does this interact with the adhoc fallback strategy (scratchpad item)?
- Should
roslyn_configurebe a tool (agent-controlled) or strictly env var (user-controlled)? - When read-write adapter disconnects, should pending read-only requests drain or fail immediately?
- Distributed/network workspace sharing (different machines)
- Authentication/authorization between adapter and service
- Hot-reloading the service without dropping connections
- Supporting non-Roslyn tools through the same pipe