Skip to main content

rust_analyzer_mcp/mcp/
server.rs

1use anyhow::Result;
2use log::{debug, error, info, warn};
3use serde_json::json;
4use std::path::PathBuf;
5use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter};
6
7use crate::{
8    lsp::RustAnalyzerClient,
9    protocol::mcp::{MCPError, MCPRequest, MCPResponse},
10    settings::Settings,
11    uri,
12};
13
14pub struct RustAnalyzerMCPServer {
15    pub(super) client: Option<RustAnalyzerClient>,
16    pub(super) workspace_root: PathBuf,
17    /// What rust-analyzer is asked to run with, for every rust-analyzer this server starts.
18    pub(super) settings: Settings,
19}
20
21impl Default for RustAnalyzerMCPServer {
22    fn default() -> Self {
23        Self::new()
24    }
25}
26
27impl RustAnalyzerMCPServer {
28    pub fn new() -> Self {
29        Self {
30            client: None,
31            workspace_root: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
32            settings: Settings::default(),
33        }
34    }
35
36    pub fn with_workspace(workspace_root: PathBuf) -> Self {
37        Self {
38            client: None,
39            workspace_root: uri::absolute(&workspace_root),
40            settings: Settings::default(),
41        }
42    }
43
44    /// Runs rust-analyzer with `settings`, whatever workspace it is pointed at.
45    pub fn with_settings(mut self, settings: Settings) -> Self {
46        self.settings = settings;
47        self
48    }
49
50    pub(super) async fn ensure_client_started(&mut self) -> Result<()> {
51        // rust-analyzer does die on occasion (it panics on some requests, see open_document());
52        // keeping a dead client around would fail every tool call for the rest of this server's
53        // life, so respawn it instead.
54        if let Some(client) = &mut self.client {
55            if client.is_gone() {
56                match client.exit_status() {
57                    Some(status) => warn!("rust-analyzer exited ({status}), restarting it"),
58                    None => warn!("rust-analyzer closed its connection, restarting it"),
59                }
60                self.client = None;
61            }
62        }
63
64        if self.client.is_none() {
65            let mut client =
66                RustAnalyzerClient::new(self.workspace_root.clone(), self.settings.to_json());
67            client.start().await?;
68            self.client = Some(client);
69        }
70        Ok(())
71    }
72
73    pub(super) async fn open_document_if_needed(&mut self, file_path: &str) -> Result<String> {
74        let path = self.resolve_path(file_path);
75        let uri = uri::path_to_uri(&path)?;
76        let content = tokio::fs::read_to_string(&path)
77            .await
78            .map_err(|e| anyhow::anyhow!("Failed to read file {}: {}", path.display(), e))?;
79
80        let Some(client) = &mut self.client else {
81            return Err(anyhow::anyhow!("Client not initialized"));
82        };
83
84        client.open_document(&uri, &content).await?;
85        Ok(uri)
86    }
87
88    /// Brings rust-analyzer up to date with every document it has been told about.
89    ///
90    /// One document at a time is enough for a question about one file, but a rename reaches
91    /// across the workspace and is worked out from whatever rust-analyzer holds for each file it
92    /// touches. Anything stale in there comes back as an edit to a line that has moved.
93    pub(super) async fn refresh_open_documents(&mut self) -> Result<()> {
94        let Some(client) = &mut self.client else {
95            return Err(anyhow::anyhow!("Client not initialized"));
96        };
97
98        for uri in client.open_document_uris().await {
99            let Some(path) = uri::uri_to_path(&uri) else {
100                continue;
101            };
102
103            match tokio::fs::read_to_string(&path).await {
104                Ok(content) => client.open_document(&uri, &content).await?,
105                // Gone from disk, which a rename of a module's file does to it. Left open, it
106                // would go on existing as far as rust-analyzer is concerned.
107                Err(_) => client.close_document(&uri).await?,
108            }
109        }
110
111        Ok(())
112    }
113
114    /// The file a tool call's `file_path` argument names.
115    ///
116    /// Clients spell that argument every way they have one to hand: relative to the workspace
117    /// root, absolute, or as the `file:` URI our own results are full of.
118    pub(super) fn resolve_path(&self, file_path: &str) -> PathBuf {
119        let path = match uri::uri_to_path(file_path) {
120            Some(path) => path,
121            // Joining an absolute path onto the root yields that path, so this covers both.
122            None => self.workspace_root.join(file_path),
123        };
124
125        uri::absolute(&path)
126    }
127
128    /// Runs the server until its stdin reaches EOF or a shutdown signal arrives.
129    ///
130    /// Installs process-wide signal handlers that remain in effect after this returns. Reads
131    /// stdin through [`tokio::io::stdin`], whose parked blocking read cannot be cancelled: after
132    /// a signal-triggered exit the caller must not wait for the runtime to shut down on its own.
133    /// See this crate's `main.rs`, which uses [`tokio::runtime::Runtime::shutdown_background`].
134    pub async fn run(&mut self) -> Result<()> {
135        info!("Starting rust-analyzer MCP server");
136
137        let stdin = tokio::io::stdin();
138        let stdout = tokio::io::stdout();
139        let mut reader = BufReader::new(stdin);
140        let mut writer = BufWriter::new(stdout);
141
142        // Created once, up front: the streams buffer signals delivered while a request is being
143        // handled, and installing a handler permanently replaces the default disposition, so
144        // every signal must be consumed here to have an effect.
145        let mut shutdown = ShutdownSignal::new()?;
146        // How many shutdown signals were consumed; the second one escalates the cleanup below.
147        let mut signals_seen = 0u32;
148        // The first fatal I/O error, reported only after the cleanup ran.
149        let mut result = Ok(());
150
151        loop {
152            let mut line = String::new();
153            // read_line() is not cancellation-safe, but the partially read line is only lost when
154            // we shut down and discard it anyway.
155            let bytes_read = tokio::select! {
156                // Biased with the signal arm first: a signal that latched while a request was
157                // being handled must win over lines already buffered on stdin, so that no new
158                // request is accepted after shutdown was requested.
159                biased;
160                _ = shutdown.recv() => {
161                    info!("Received shutdown signal");
162                    signals_seen += 1;
163                    break;
164                }
165                read = reader.read_line(&mut line) => match read {
166                    Ok(n) => n,
167                    Err(e) => {
168                        error!("Error reading from stdin: {}", e);
169                        result = Err(e.into());
170                        break;
171                    }
172                },
173            };
174
175            if bytes_read == 0 {
176                break; // EOF
177            }
178
179            let line = line.trim();
180            if line.is_empty() {
181                continue;
182            }
183
184            let Ok(request) = serde_json::from_str::<MCPRequest>(line) else {
185                debug!("Failed to parse request: {}", line);
186                continue;
187            };
188
189            // A message without an `id` is a JSON-RPC notification, which must never be answered,
190            // not even with an error: a client that receives a response it did not ask for treats
191            // it as a protocol violation and closes the transport. `notifications/initialized` is
192            // part of every MCP handshake, so this used to break every spec-compliant client.
193            if request.id.is_none() {
194                debug!("Ignoring notification: {}", request.method);
195                continue;
196            }
197
198            debug!("Received request: {}", request.method);
199            // A shutdown signal must not wait for the request to finish: a tool call that
200            // cold-starts rust-analyzer can run for minutes.
201            let response = tokio::select! {
202                biased;
203                _ = shutdown.recv() => {
204                    info!("Received shutdown signal");
205                    signals_seen += 1;
206                    break;
207                }
208                response = self.handle_request(request) => response,
209            };
210            // Break on errors instead of returning so rust-analyzer still gets cleaned up.
211            let response_json = match serde_json::to_string(&response) {
212                Ok(json) => json,
213                Err(e) => {
214                    error!("Failed to serialize response: {}", e);
215                    result = Err(e.into());
216                    break;
217                }
218            };
219            // Also raced against the signals: if the host stops reading stdout, a response that
220            // fills the pipe would otherwise block here forever with the signals unpolled.
221            let written = async {
222                writer.write_all(response_json.as_bytes()).await?;
223                writer.write_all(b"\n").await?;
224                writer.flush().await
225            };
226            let written = tokio::select! {
227                biased;
228                _ = shutdown.recv() => {
229                    info!("Received shutdown signal");
230                    signals_seen += 1;
231                    break;
232                }
233                written = written => written,
234            };
235            if let Err(e) = written {
236                error!("Error writing to stdout: {}", e);
237                result = Err(e.into());
238                break;
239            }
240        }
241
242        // Cleanup. client.shutdown() bounds its own graceful handshake and always ends up
243        // killing the process, so this cannot stall. A second signal — counting the one that may
244        // have triggered the exit — skips the handshake and kills rust-analyzer immediately.
245        info!("Shutting down");
246        if let Some(client) = &mut self.client {
247            let graceful = {
248                let shutting_down = client.shutdown();
249                tokio::pin!(shutting_down);
250                loop {
251                    tokio::select! {
252                        biased;
253                        _ = shutdown.recv() => {
254                            signals_seen += 1;
255                            if signals_seen >= 2 {
256                                info!("Received another shutdown signal, killing rust-analyzer");
257                                break false;
258                            }
259                        }
260                        res = &mut shutting_down => {
261                            let _ = res;
262                            break true;
263                        }
264                    }
265                }
266            };
267            if !graceful {
268                client.force_kill().await;
269            }
270        }
271
272        result
273    }
274
275    async fn handle_request(&mut self, request: MCPRequest) -> MCPResponse {
276        match request.method.as_str() {
277            "initialize" => MCPResponse::Success {
278                jsonrpc: "2.0".to_string(),
279                id: request.id,
280                result: json!({
281                    "protocolVersion": "2024-11-05",
282                    "serverInfo": {
283                        "name": "rust-analyzer-mcp",
284                        "version": env!("CARGO_PKG_VERSION")
285                    },
286                    "capabilities": {
287                        "tools": {}
288                    }
289                }),
290            },
291            // A liveness check the client may send at any point, including before `initialize`.
292            // Its result is empty; what matters is that it comes back at all.
293            "ping" => MCPResponse::Success {
294                jsonrpc: "2.0".to_string(),
295                id: request.id,
296                result: json!({}),
297            },
298            "tools/list" => MCPResponse::Success {
299                jsonrpc: "2.0".to_string(),
300                id: request.id,
301                result: json!({
302                    "tools": super::tools::get_tools()
303                }),
304            },
305            "tools/call" => {
306                let Some(params) = request.params else {
307                    return MCPResponse::Error {
308                        jsonrpc: "2.0".to_string(),
309                        id: request.id,
310                        error: MCPError {
311                            code: -32602,
312                            message: "Invalid params".to_string(),
313                            data: None,
314                        },
315                    };
316                };
317
318                let Some(tool_name) = params["name"].as_str() else {
319                    return MCPResponse::Error {
320                        jsonrpc: "2.0".to_string(),
321                        id: request.id,
322                        error: MCPError {
323                            code: -32602,
324                            message: "Missing tool name".to_string(),
325                            data: None,
326                        },
327                    };
328                };
329
330                let args = params
331                    .get("arguments")
332                    .cloned()
333                    .unwrap_or_else(|| json!({}));
334
335                match super::handlers::handle_tool_call(self, tool_name, args).await {
336                    Ok(result) => MCPResponse::Success {
337                        jsonrpc: "2.0".to_string(),
338                        id: request.id,
339                        result: serde_json::to_value(result).unwrap(),
340                    },
341                    Err(e) => {
342                        error!("Tool call error: {}", e);
343                        MCPResponse::Error {
344                            jsonrpc: "2.0".to_string(),
345                            id: request.id,
346                            error: MCPError {
347                                code: -1,
348                                message: e.to_string(),
349                                data: None,
350                            },
351                        }
352                    }
353                }
354            }
355            _ => MCPResponse::Error {
356                jsonrpc: "2.0".to_string(),
357                id: request.id,
358                error: MCPError {
359                    code: -32601,
360                    message: format!("Method not found: {}", request.method),
361                    data: None,
362                },
363            },
364        }
365    }
366}
367
368/// Merged stream of the signals that request server shutdown.
369///
370/// SIGINT, SIGTERM and SIGHUP on Unix; Ctrl+C and console-close events on Windows. The streams
371/// are persistent, so signals delivered while no `recv()` is pending stay latched instead of
372/// falling through to the default disposition. Note that registering SIGHUP also overrides an
373/// inherited SIG_IGN disposition (e.g. from nohup), so a hangup always shuts the server down.
374struct ShutdownSignal {
375    #[cfg(unix)]
376    sigint: tokio::signal::unix::Signal,
377    #[cfg(unix)]
378    sigterm: tokio::signal::unix::Signal,
379    #[cfg(unix)]
380    sighup: tokio::signal::unix::Signal,
381    #[cfg(windows)]
382    ctrl_c: tokio::signal::windows::CtrlC,
383    #[cfg(windows)]
384    ctrl_close: tokio::signal::windows::CtrlClose,
385}
386
387impl ShutdownSignal {
388    fn new() -> Result<Self> {
389        #[cfg(unix)]
390        {
391            use tokio::signal::unix::{signal, SignalKind};
392
393            Ok(Self {
394                sigint: signal(SignalKind::interrupt())?,
395                sigterm: signal(SignalKind::terminate())?,
396                sighup: signal(SignalKind::hangup())?,
397            })
398        }
399        #[cfg(windows)]
400        {
401            use tokio::signal::windows;
402
403            Ok(Self {
404                ctrl_c: windows::ctrl_c()?,
405                ctrl_close: windows::ctrl_close()?,
406            })
407        }
408        #[cfg(not(any(unix, windows)))]
409        Ok(Self {})
410    }
411
412    /// Completes when the next shutdown signal arrives. Cancellation-safe.
413    async fn recv(&mut self) {
414        #[cfg(unix)]
415        {
416            tokio::select! {
417                _ = self.sigint.recv() => {}
418                _ = self.sigterm.recv() => {}
419                _ = self.sighup.recv() => {}
420            }
421        }
422        #[cfg(windows)]
423        {
424            tokio::select! {
425                _ = self.ctrl_c.recv() => {}
426                _ = self.ctrl_close.recv() => {}
427            }
428        }
429        #[cfg(not(any(unix, windows)))]
430        {
431            // No signal support; only a stdin EOF stops the server.
432            std::future::pending::<()>().await;
433        }
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    #[test]
442    fn file_paths_are_resolved_however_they_are_spelled() {
443        let server = RustAnalyzerMCPServer::with_workspace(workspace());
444        let absolute = server.workspace_root.join("src/lib.rs");
445        let uri = uri::path_to_uri(&absolute).unwrap();
446
447        for spelling in ["src/lib.rs", &absolute.display().to_string(), &uri] {
448            let resolved = server.resolve_path(spelling);
449
450            assert_eq!(resolved, absolute, "{spelling}");
451            // Equality alone would not catch the Windows extended-length form, which compares
452            // equal to the path meant while being unusable.
453            assert!(std::fs::read_to_string(&resolved).is_ok(), "{spelling}");
454        }
455    }
456
457    #[test]
458    fn a_path_that_does_not_exist_still_resolves() {
459        // Nothing to canonicalize against, but the error belongs to whoever reads the file.
460        let server = RustAnalyzerMCPServer::with_workspace(workspace());
461        let missing = server.workspace_root.join("src/nowhere.rs");
462
463        assert_eq!(server.resolve_path("src/nowhere.rs"), missing);
464    }
465
466    /// A real directory, so that `canonicalize()` has something to work with.
467    fn workspace() -> PathBuf {
468        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test-project")
469    }
470}