To Async or Not to Async: Building a Rust MCP Server for rust-analyzer
- Pragmatism Over Dogma
- How We Got to βAsync-by-Defaultβ
- 1:1 Process Pipes vs. 1:N Network Servers
- The 3-Thread Architecture
- Advantages
- Choosing the Right Tool
Pragmatism Over Dogma #
When we build tools in Rust where I/O is involved, we might feel a strong pull toward
βasync all the thingsβ. It is understandable that we may instinctively run
cargo add tokio, decorate the main function with #[tokio::main], make all the
functions async, and add .await to the call sites.
I love both synchronous and asynchronous Rust. This article is not an ideological critique of asynchronous Rust. This article is in support of using the right tool for the job, and not the wrong one (even when that tool is our beloved Tokio).
As the author of r3bl_tui, I have spent years building asynchronous foundations for
terminal applications. In r3bl_tui, everything from custom readline primitives and
asynchronous signal listeners to multi-producer event-driven render loops is built with
async Rust from the ground up to uphold the core invariant: never block the main
thread.
To achieve this, the r3bl_tui crate cleanly separates these responsibilities:
- Async Event Multiplexing (with Tokio Primitives) - It relies on Tokio constructs
(such as
tokio::select!, asynchronous tasks,mpscandbroadcastchannels) to multiplex high-frequency user inputs (e.g., keypresses, mouse drags, window resize events) and background worker streams concurrently without task starvation. - Blocking I/O Isolation via Resilient Reactor Thread (RRT) - Invoking
blocking system calls (like reading raw
stdin) directly on async executor threads would starve Tokio and freeze the terminal UI. So, I designed the RRT pattern. RRT wraps blocking I/O sources (and allows me to eliminate external dependencies likecrossterm) and isolates them inside dedicated, self-healing OS threads that feed non-blocking broadcast channels into the async loop. This pattern is easily extensible to other blocking sources, modern completion engines likeio_uring, and is even a good fit for in-process server use cases.
With that out of the way, hereβs the βbutβ. Great engineering is about fit for purpose, not dogmatic conformity. Applying a high-concurrency async runtime designed for web servers handling hundreds of thousands of concurrent TCP sockets to a 1:1 local standard I/O pipe introduces accidental complexity without providing performance, readability, or maintainability benefits.
The motivation to write this article happened recently, after I built
r3bl-rust-analyzer-mcp-server, a Model Context Protocol (MCP) server designed to
bridge AI / LLM coding agents (such as Google Antigravity, Claude Code, and Cursor) with
the rust-analyzer Language Server Protocol (LSP) subprocess. Why build yet another
crate to do this? Because a few existing Rust MCP server crates that I tried didnβt work
with Antigravity, and some of them were consuming a lot of CPU and crashing.
I made a deliberate (dare I say, counter-cultural) architectural choice and completely
omitted Tokio in favor of standard library OS threads and channels over stdio.
This article explores the engineering rationale behind that choice. We will examine the mechanics of standard I/O pipes on Linux, macOS and Windows, contrast the 1:1 workload of local AI / LLM coding agents against 1:N networked servers, and break down the simple 3-thread architecture that delivers the following benefits:
- Sub-millisecond cold starts (<2ms). Spawns instantly without the overhead of Tokio runtime initialization, thread-pool spin-up, or timer wheel setup.
- Eliminates stream corruption caused by futures dropping mid-write at
.awaitpoints and prevents orphaned zombierust-analyzerprocesses. - Easy to understand architecture and code. Control flow is linear, and the type system makes invalid states unrepresentable.
How We Got to βAsync-by-Defaultβ #
Anthropic released the Model Context Protocol specification in November 2024,
soon after which they released the official Rust SDK rmcp.
By architecting rmcp around Tokio (#[tokio::main], tokio::io::{stdin, stdout}, and
async traits), Anthropic established a default pattern that future Rust MCP
crates would mimic.
All the open source Rust MCP server for rust-analyzer (or generic LSP gateway) crates
that I was able to find have been built with Tokio, including:
lsp-mcp: A generic LSP-to-MCP gateway built with Tokio.dexwritescode/rust-mcp: Arust-analyzerMCP server built on Tokio.ciresnave/rust-analyzer-mcp-server: Arust-analyzerbridge on Tokio.zeenix/rust-analyzer-mcp: Arust-analyzerbridge built on Tokio.
All of these binary crates are designed to run as local child processes communicating
strictly over stdio.
So, why do they all use Tokio?
Perhaps the authors of these crates implicitly accepted Anthropicβs design choices as correct, without critically examining them. Or perhaps the AI / LLM coding agents (used by the authors to create these crates) blindly accepted Anthropicβs choice. The power of defaults? I have no way of knowing.
When the official reference SDK uses Tokio, it is easy for an entire ecosystem to assume that an async runtime is a prerequisite for writing an MCP server.
TL;DR: An asynchronous runtime is the wrong tool for local standard I/O streams / pipes. For 1:1 process pipes, synchronous threads are faster, simpler, and far more reliable.
1:1 Process Pipes vs. 1:N Network Servers #
To understand why synchronous threads are optimal for a local MCP server, we must look at
how an AI / LLM coding agent actually interacts with its tools (like the
rust-analyzer-mcp-server binary in the r3bl-rust-analyzer-mcp-server crate).
1. The End-to-End Flow #
The complete interaction workflow (User -> AI / LLM Coding Agent -> MCP Server ->
rust-analyzer) consists of these steps:
- User Launches Agent
- The user starts their AI / LLM coding agent (such as Google Antigravity
agy, Claude Code, Cursor, or VS Code) in a local folder containing a Rust project.
- The user starts their AI / LLM coding agent (such as Google Antigravity
- Agent Configuration
- The user configures the agent to register
rust-analyzer-mcp-serverbinary as a local tool provider (via its MCP configuration mechanism) and gives it a name likerust-refactor.
- The user configures the agent to register
- User Prompts the Agent
- The user issues a prompt requiring Rust semantic analysis (e.g., βUse rust-refactor
to find all references to
MyStructand refactor its constructorβ).
- The user issues a prompt requiring Rust semantic analysis (e.g., βUse rust-refactor
to find all references to
- Agent Spawns Bridge Process
- To execute the requested tools, the AI / LLM coding agent spawns
rust-analyzer-mcp-serveras a dedicated 1:1 child process communicating over standard I/O (stdinandstdout).
- To execute the requested tools, the AI / LLM coding agent spawns
- Bridge Translates MCP to LSP
- The MCP server receives JSON-RPC tool calls over
stdin, converts them into LSP queries forrust-analyzer, and writes formatted responses tostdout:
- The MCP server receives JSON-RPC tool calls over
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β HOST PROCESS: AI / LLM CODING AGENT β
β (Antigravity / VSCode / Claude) β
βββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββ
β
[Launches rust-analyzer-mcp-server binary]
(1:1 dedicated child process)
β
stdin / stdout (MCP: Newline-delimited JSON-RPC)
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β rust-analyzer-mcp-server β
β (3 OS threads: Main, stdout-reader, stderr-reader) β
β β
β 1. Reads MCP JSON-RPC tool calls from stdin β
β 2. Converts tool calls into LSP JSON-RPC queries β
β 3. Writes LSP requests to rust-analyzer stdin β
β 4. Receives LSP responses from background thread β
β 5. Formats and writes MCP responses to stdout β
βββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββ
β
[Spawns rust-analyzer subprocess]
(1:1 dedicated child process)
β
stdin / stdout (LSP: Header-delimited JSON-RPC)
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β LANGUAGE SERVER: rust-analyzer subprocess β
β (The Rust Language Server) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- Process Lifecycle: The AI / LLM coding agent (e.g., Antigravity, Claude Code,
Cursor) spawns
rust-analyzer-mcp-serveras a direct, dedicated child process for the duration of the editing session. - Channel: Communication occurs exclusively over standard OS anonymous pipes (
stdinandstdout).
2. How MCP Layers Over LSP #
The Model Context Protocol (MCP) does not define language-server-specific endpoints. Instead, MCP provides generic JSON-RPC primitives:
initialize: Handshake and capability negotiation.tools/list: Dynamic tool discovery via JSON Schemas.tools/call: Generic tool execution request and response.
rust-analyzer-mcp-server serves as an adapter: it implements those standard MCP
endpoints on the outside and internally translates them into rust-analyzerβs Language
Server Protocol (LSP 3.17) queries.
Protocol Layering Flow #
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β AI / LLM CODING AGENT (agy) β
βββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββ
β
1. "tools/list" (Standard MCP)
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β rust-analyzer-mcp-server β
β β
β Returns list of 10 tool schemas: β
β β’ rust_analyzer_hover β
β β’ rust_analyzer_definition β
β β’ rust_analyzer_references β
β β’ rust_analyzer_diagnostics β
β β’ ... β
βββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββ
β
2. Agent injects tool schemas into LLM
β
3. LLM decides: call "rust_analyzer_hover"
β
4. "tools/call" (Standard MCP)
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β rust-analyzer-mcp-server β
β β
β Translates tools/call -> textDocument/hover (LSP) β
βββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββ
β
5. LSP JSON-RPC over stdio
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β rust-analyzer subprocess β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
The sections below trace this execution flow chronologically:
- Discovery (
tools/list): The AI / LLM coding agent queries capabilities; our MCP server immediately replies with tool schemas that we define, without invokingrust-analyzer. - Tool Request (
tools/call): The AI / LLM coding agent dispatches a tool execution request, e.g.,rust_analyzer_hoverwith a file path and coordinates (line: 42, character: 10). - Bridge Translation (MCP -> LSP): Our MCP server translates the MCP tool call into an
LSP query (
textDocument/hover), writes it torust-analyzer, and awaits the AST response. - Tool Response (
CallToolResult):rust-analyzerproduces the symbol information (with hover documentation and type signatures natively formatted in Markdown). Our MCP server packages this into a standard MCPCallToolResultand returns the JSON-RPC response to the AI / LLM coding agent with the matching request ID.
1. Dynamic Tool Discovery (tools/list) #
When the AI / LLM coding agent (e.g. agy) spawns our MCP server, it queries available
tools by sending an MCP tools/list request over stdin:
{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }
rust-analyzeris not used at all to produce this response. Becauserust-analyzeris a pure LSP language server with zero awareness of MCP, our bridge binary (rust-analyzer-mcp-server) statically defines and serves all 10 tool descriptors, documentation, and JSON Schemas directly from its internal catalog.
Our MCP server writes this JSON-RPC response back to the AI / LLM coding agent over
stdout:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [
{
"name": "rust_analyzer_hover",
"description": "Hover documentation, types, and signatures for a symbol.",
"inputSchema": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute file path"
},
"line": {
"type": "integer",
"description": "0-based line number"
},
"character": {
"type": "integer",
"description": "0-based character offset"
}
},
"required": ["file_path", "line", "character"]
}
},
{
"name": "rust_analyzer_definition",
"description": "Find symbol definition location.",
"inputSchema": {}
}
]
}
}
The coding agent registers these schemas with the LLM, enabling the model to invoke any of the 10 tools when inspecting code.
2. Tool Execution (tools/call) #
When the LLM decides to inspect a symbol at line 42, character 10, the coding agent sends
a standard MCP tools/call request to our MCP serverβs stdin:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "rust_analyzer_hover",
"arguments": {
"file_path": "/path/to/main.rs",
"line": 42,
"character": 10
}
}
}
3. The Bridge Translation: MCP to LSP #
Our MCP server translates the tool name and arguments into an LSP 3.17 request:
| Standard MCP Method / Tool Name | Translates To LSP 3.17 Method |
|---|---|
tools/call (rust_analyzer_hover) |
textDocument/hover |
tools/call (rust_analyzer_definition) |
textDocument/definition |
tools/call (rust_analyzer_references) |
textDocument/references |
tools/call (rust_analyzer_symbols) |
textDocument/documentSymbol |
tools/call (rust_analyzer_completion) |
textDocument/completion |
tools/call (rust_analyzer_format) |
textDocument/formatting |
tools/call (rust_analyzer_code_actions) |
textDocument/codeAction |
tools/call (rust_analyzer_diagnostics) |
textDocument/diagnostic (or push fallback) |
tools/call (rust_analyzer_workspace_diagnostics) |
workspace/diagnostic |
tools/call (rust_analyzer_set_workspace) |
workspace/didChangeConfiguration |
4. Returning the Response to the AI / LLM Coding Agent #
rust-analyzer answers the LSP query with symbol data (for textDocument/hover, it
natively formats the Rust signature and doc comments as Markdown in its MarkupContent
response). Our MCP server packages this into a standard MCP CallToolResult text payload
and returns it:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [
{
"type": "text",
"text": "```rust\nfn foo()->bool\n```\nDoes stuff..."
}
]
}
}
The coding agent passes this text directly back into the LLM context so it can continue reasoning.
3. No Need To Multiplex Over stdio #
Neither JSON-RPC 2.0 nor the MCP specification dictates the transport layer. You can run
JSON-RPC over WebSockets, TCP, Unix domain sockets, or stdio. Over network sockets (such
as TCP or WebSockets), async request multiplexing makes sense, since thousands of remote
clients can connect to a single daemon, each over its own independent socket connection.
However, when an AI / LLM coding agent launches our MCP server locally, communication
relies on OS anonymous pipes (stdin and stdout). An OS pipe is a unidirectional,
in-memory FIFO buffer in the kernel (typically 64 KB on Linux). Because these are single
serialized byte streams, communication between the coding agent and our server is
inherently sequential by design:
stdin(Accepting Requests from the Coding Agent): The coding agent writes MCP JSON-RPC requests into our MCP serverβsstdinpipe, delimited by newlines (\n). Reading fromstdinmust be serialized: multiple concurrent reader threads would race to consume chunks from the byte stream, splitting JSON lines in half and breaking JSON parsing. A single synchronous reader loop in our main thread naturally consumes incoming requests line-by-line with zero synchronization overhead.stdout(Returning Responses to the Coding Agent): Our MCP server writes JSON-RPC tool responses back to the coding agent overstdout. Multiple concurrent threads or tasks cannot write tostdoutsimultaneously without garbling the response stream. Even if a multi-threaded async runtime executes tool queries concurrently, all tasks must ultimately synchronize on a mutex to write their responses line-by-line. An async runtime cannot provide parallel I/O throughput over a single pipe; it simply adds mutex contention and task scheduling overhead.- Turn-Based Agent Structure: LLM workflows operate in discrete conversational turns.
When the LLM generates a tool call, the coding agent writes the JSON-RPC request data
to our MCP serverβs
stdinand waits for the tool output before prompting the model for the next reasoning step. Because AST tool execution (done byrust-analyzer) is so fast (1 to 10 ms) compared to LLM inference (500 to 3,000 ms), sequential execution latency is completely imperceptible to the user. -
The Synchronous Event Loop: In
src/mcp/server.rs, the main thread runs a single-threaded line reader with no lock contention:// Block and read incoming JSON-RPC lines sequentially. for line in stdin.lock().lines() { let line = line?; let trimmed_line = line.trim(); if trimmed_line.is_empty() { continue; } // Parse the MCP request payload. let request_payload = serde_json::from_str::<McpRequest>(trimmed_line); let Ok(request) = request_payload else { continue; }; // Synchronously execute the tool query against rust-analyzer. let response_payload = self.handle_request(request); if let Some(response) = response_payload { // Write the response back to stdout. serde_json::to_writer(&mut stdout, &response)?; stdout.write_all(b"\n")?; stdout.flush()?; } }Each MCP request is consumed, translated into an LSP request, synchronously resolved against
rust-analyzer, and the response is written back tostdoutbefore the next line is read.
4. The Illusion of Async stdio #
A common misconception in the Rust ecosystem is that calling
tokio::io::stdin().read_line(&mut buf).await transforms standard input into
non-blocking, asynchronous I/O. It does not.
How Tokio Reads stdin Under the Hood #
On Unix-like operating systems (Linux, macOS, BSD), OS kernel event notification
mechanisms (epoll and kqueue) are designed for network sockets, event fds, and
specific character devices. stdin cannot be reliably polled via epoll edge-triggered
event loops without edge-case stalls.
To provide an AsyncRead interface over stdin, Tokioβs tokio::io::stdin() literally:
- Spawns a dedicated OS worker thread via
spawn_blocking. - Runs a standard blocking
libc::read(0, ...)call on that background thread. - Copies incoming bytes across an internal async channel to wake up your Tokio task.
Tokio ends up doing the exact same thing we do by spawning a dedicated thread with a blocking read. However, Tokio also adds all the async runtime machinery (channel handoffs, and task scheduler overhead) on top just to maintain the illusion of being
async!
The Workload Reality: There is Nothing to Multiplex #
In a web server or network gateway, async runtimes shine because a single daemon must multiplex potentially thousands of idle TCP connections waiting for bytes.
For a local MCP server, however, the coding agent operates in discrete conversational turns.
- It writes a tool request into our MCP serverβs
stdinpipe and pauses to await the response. - While the LLM is reasoning, generating tokens, or waiting for user input, our MCP
serverβs
stdinpipe is idle 99% of the time. Our main thread simply sits blocked waiting for the next line, with zero concurrent traffic to multiplex.
When an async runtime delegates stdio to blocking worker threads anyway, wrapping that
pipeline inside a multi-threaded async state machine does not make it faster or better. It
simply adds runtime initialization overhead, future state machine transformations, and
task scheduling latency. It is simply not fit for this purpose, adding complexity without
providing any of the benefits of asynchronous I/O.
The 3-Thread Architecture #
While communication from the AI / LLM coding agent to the MCP server over stdio is
synchronous and sequential, communication between the MCP server and rust-analyzer is
inherently asynchronous. rust-analyzer can emit unprompted notifications (such as
compiler diagnostics via textDocument/publishDiagnostics or indexing progress via
experimental/serverStatus) at any time, interleaved with query responses.
To handle this cleanly without an async runtime, r3bl-rust-analyzer-mcp-server uses
exactly 3 dedicated OS threads:
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β r3bl-rust-analyzer-mcp-server β
β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β 1. MAIN THREAD (main) β β
β β β’ Reads MCP JSON-RPC from coding agent stdin. β β
β β β’ Translates tool calls to LSP JSON-RPC. β β
β β β’ Writes query to rust-analyzer stdin writer. β β
β β β’ Blocks on single-use sync_channel(1) receiver. β β
β β β’ Formats ToolResult & writes response to stdout. β β
β ββββββββββββββββ¬βββββββββββββββββββββββββ²βββββββββββββββββ β
β β β β
β β Writes LSP β Delivers payload β
β β Request β via SyncSender β
β βΌ β β
β ββββββββββββββββββββββββββββ ββββββββ΄βββββββββββββββββ β
β β rust-analyzer stdin pipe β β pending_requests: β β
β ββββββββββββββββββββββββββββ β Arc<Mutex<HashMap>> β β
β ββββββββ²βββββββββββββββββ β
β β β
β βββββββββββββββββββββββββββββββββββββββββ΄βββββββββββββββββ β
β β 2. LSP STDOUT READER THREAD (lsp-stdout-reader) β β
β β β’ Reads Content-Length framed JSON-RPC from RA. β β
β β β’ Matches ID to pending_requests & unblocks Main. β β
β β β’ Ingests compiler diagnostics & server status. β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β 3. LSP STDERR READER THREAD (lsp-stderr-reader) β β
β β β’ Continuously drains rust-analyzer stderr pipe. β β
β β β’ Forwards stderr lines to structured tracing. β β
β β β’ CRITICAL: Prevents 64 KB kernel pipe deadlocks. β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Thread 1: Main MCP Loop #
The Main Thread is the orchestrator. It executes the synchronous MCP loop:
- Reads a line from
std::io::stdin(). - Parses the MCP JSON-RPC payload.
- If it is a tool invocation (e.g.
rust_analyzer_hoverorrust_analyzer_definition), it converts the request into an LSP message and dispatches it viasend_request(). - Writes the MCP response back to
std::io::stdout().
Thread 2: stdout Reader #
Spawned immediately after the rust-analyzer subprocess is launched, this background
thread takes ownership of the childβs stdout pipe and runs a dedicated loop parsing
Content-Length: ...\r\n\r\n framed messages:
- Response Payloads: Extracts the
id, looks up the transmitter inpending_requests, and unblocks the Main Thread viasender.send(payload). - Compiler Diagnostics: Ingests
textDocument/publishDiagnosticsnotifications into a thread-safe cache (Arc<Mutex<HashMap<String, Vec<Value>>>>). - Indexing Progress: Parses
experimental/serverStatusnotifications and updates the thread-safeServerReadinessMonitor.
Thread 3: stderr Drainer #
Many language server bridges suffer from subtle, intermittent freezes. On Linux and macOS, anonymous pipes have a default kernel buffer capacity of 64 KB.
If rust-analyzer prints heavy debug tracing, cargo warnings, or proc-macro diagnostic
messages to stderr, and the parent process does not read stderr, the 64 KB kernel
buffer fills up. Once full, any subsequent write by rust-analyzer to stderr blocks the
entire compiler process at the OS kernel level, deadlocking the server indefinitely.
Thread 3 continuously drains stderr in a tight loop and routes the lines to structured
tracing logs, guaranteeing that rust-analyzerβs stderr pipe never fills up or
deadlocks.
Request-Response Via sync_channel(1) #
Instead of complex async runtime state machines, request-response synchronization between
Thread 1 and Thread 2 uses a standard library synchronous channel with a buffer capacity
of 1 (std::sync::mpsc::sync_channel(1)).
Thread 1 creates a fresh channel pair for each outbound LSP request, registers the
transmitter in the pending_requests thread-safe lookup table keyed by request ID, and
blocks on the receiver until Thread 2 delivers the response:
pub fn send_request(
&mut self,
method: &str,
params: Option<Value>,
) -> Result<Value, McpServerError> {
// Generate unique request ID.
let request_id = self.next_request_id();
// Allocate single-use channel with buffer capacity 1.
let (tx, rx) = std::sync::mpsc::sync_channel(1);
// Register transmitter in pending_requests table.
{
let mut guard = self.pending_requests.lock().unwrap();
guard.insert(request_id, tx);
}
// Write framed LSP request to rust-analyzer stdin.
self.write_lsp_message(request_id, method, params)?;
// Block waiting for response with timeout.
let timeout = Duration::from_secs(10);
match rx.recv_timeout(timeout) {
Ok(response) => Ok(response),
Err(_) => {
// Clean up pending request on timeout.
self.pending_requests.lock().unwrap().remove(&request_id);
Err(McpServerError::RequestCancelled)
}
}
}
This pattern has the following advantages:
- Zero Contention: The channel has an exact capacity of 1 (
sync_channel(1)). - Deterministic Timeout: If
rust-analyzerhangs or crashes,rx.recv_timeout()cleanly unblocks the main thread without leaving dangling futures. - Automatic Cleanup: When
rxdrops, the channel is deallocated immediately.
Advantages #
By replacing Tokio with standard library threads, r3bl-rust-analyzer-mcp-server achieves
tangible advantages:
Sub-Millisecond Cold Starts #
AI / LLM coding agents frequently spin up MCP servers dynamically per repository or
worktree. Without Tokioβs runtime initialization sequence, thread-pool allocation, and
timer wheel setup, rust-analyzer-mcp-server enters its main loop and begins spawning the
rust-analyzer subprocess in less than 2 milliseconds on average (tested on an Intel Core
i7-14700 on Linux).
Transparent Stack Traces #
When debugging an issue in a multi-threaded async codebase, backtraces often look like an
maze of poll, wake, Task::run, and internal runtime state-machine steps.
In this synchronous architecture, every stack trace is transparent:
- Thread 1 is in
enter_main_event_loop->send_request->recv_timeout. - Thread 2 is in
read_exactparsingContent-Lengthheaders. - Thread 3 is in
read_linedraining stderr.
Inspecting the tracing log output gives an exact, unambiguous view of the entire application state.
Deterministic Teardown #
In async runtimes, graceful teardown often requires coordinating CancellationToken
hierarchies across worker thread pools. If an async task forgets to check the token, child
processes become orphaned zombies eating 100% CPU. Furthermore, dropping a future
mid-execution cancels it at whichever .await point it was suspended at, potentially
leaving partially written LSP messages in a shared pipe and corrupting the transport
stream.
In a synchronous thread architecture, teardown is naturally governed by standard OS pipe mechanics:
AI / LLM Coding Agent Closes Stdin (Process exits or session terminates)
β
βΌ
stdin.lock().lines() returns Ok(None) / EOF on Main Thread
β
βΌ
enter_main_event_loop exits cleanly
β
βΌ
RustAnalyzerClient::shutdown() executes (or runs in Drop):
1. Sends LSP "shutdown" request and "exit" notification.
2. Closes child stdin writer handle (signaling EOF to rust-analyzer).
3. Calls child_proc.kill() and child_proc.wait() to reap zombie PID.
4. Background reader threads reach EOF on stdout/stderr and terminate.
β
βΌ
Process exits cleanly with exit code 0 in <1ms.
This makes shutdown straightforward. When stdin reaches EOF, the main loop exits,
terminates rust-analyzer, and the reader threads finish naturally as soon as their pipes
close. There are no background tasks or child processes left behind.
Choosing the Right Tool #
The goal of this architectural comparison is not to discourage the use of Tokio or async Rust. Async Rust is one of the most powerful paradigms in modern systems programming when applied to the problem domain it was created for: high-concurrency 1:N I/O multiplexing across thousands of network sockets or complex, multi-source event-driven user interfaces.
However, for a 1:1 local standard I/O translation bridge, standard library threads and channels offer a superior design:
- Lower Cognitive Load: Simple, linear control flow without async function coloring.
- Superior Reliability: Deadlock-free 3-thread pipe isolation with deterministic EOF teardown.
- Lean Footprint: Zero runtime overhead, minimal dependencies, and instant cold starts.
Resources & Links #
- Crate: [
r3bl-rust-analyzer-mcp-server][r3bl-rust-analyzer-mcp-server] - Model Context Protocol Specification: modelcontextprotocol.io
- Language Server Protocol 3.17 Specification: language-server-protocol
- R3BL Open Core Project: github.com/r3bl-org/r3bl-open-core
π Watch Rust π¦ live coding videos on our YouTube Channel.
π¦ Install our useful Rust command line apps usingcargo install r3bl-cmdr(they are from the r3bl-open-core project):
- π±
giti: run interactive git commands with confidence in your terminal- π¦
edi: edit Markdown with style in your terminalgiti in action
edi in action