wip: BufPool improvements + document sync

Phillip Davis created

semi-vibed, need to clean up to match code conventions.
- replace all the `Result`s with `anyhow::Result`s
- I don't like that `LSPClient::setup` returns both a server and a client,
seems like it should just be at the root of the `lsp` module
- gotta remove a bunch of lints that are annoying and eating tokens
- gotta remove all the dumb agent comments
- eventually we'll have to be clever about de-allocating memory,
since at this point the LRU strategy used by `OpenedDocuments` never actually
deallocates. This logic will have to happen in `BufPool.`
- move the `LanguageId` type to its own module
- I think we should refactor all file access to go through the LSP, i.e.,
`OpenedDocuments` should be able to read the document into a buffer or something, and
this should probably be accessed through `LSPServer`.
Basically, the read tool itself will be very minimal. This ensures that all the LSP
bookkeeping and doc synchronization is handled correctly.

Change summary

Cargo.lock                  |  60 ++++++++
Cargo.toml                  |   1 
src/buf_pool.rs             |  63 ++++++--
src/config.rs               |   4 
src/lsp/client.rs           |  20 +-
src/lsp/mod.rs              |   3 
src/lsp/opened_documents.rs | 131 ++++++++++++++++++
src/lsp/server.rs           | 272 +++++++++++++++++++++++++++++++++++++++
src/mcp/server.rs           |  14 -
src/mcp/tools/read.rs       |   7 +
10 files changed, 541 insertions(+), 34 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -463,6 +463,20 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "dashmap"
+version = "6.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
+dependencies = [
+ "cfg-if",
+ "crossbeam-utils",
+ "hashbrown 0.14.5",
+ "lock_api",
+ "once_cell",
+ "parking_lot_core",
+]
+
 [[package]]
 name = "displaydoc"
 version = "0.2.5"
@@ -652,6 +666,12 @@ version = "0.32.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
 
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
 [[package]]
 name = "hashbrown"
 version = "0.16.0"
@@ -814,7 +834,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5"
 dependencies = [
  "equivalent",
- "hashbrown",
+ "hashbrown 0.16.0",
 ]
 
 [[package]]
@@ -874,6 +894,15 @@ version = "0.8.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
 
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
 [[package]]
 name = "log"
 version = "0.4.28"
@@ -903,6 +932,7 @@ dependencies = [
  "async-process",
  "clap",
  "crossbeam",
+ "dashmap",
  "futures",
  "rmcp",
  "serde",
@@ -993,6 +1023,19 @@ version = "2.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
 
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
 [[package]]
 name = "paste"
 version = "1.0.15"
@@ -1069,6 +1112,15 @@ dependencies = [
  "proc-macro2",
 ]
 
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.9.4",
+]
+
 [[package]]
 name = "ref-cast"
 version = "1.0.25"
@@ -1197,6 +1249,12 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
 [[package]]
 name = "serde"
 version = "1.0.228"

Cargo.toml 🔗

@@ -10,6 +10,7 @@ async-lsp = "0.2.2"
 async-process = "2.3"
 clap = { version = "4.5.48", features = ["derive"] }
 crossbeam = "0.8"
+dashmap = "6.1.0"
 futures = "0.3.31"
 rmcp = { version = "0.8.0", features = ["server", "transport-io"] }
 serde = { version = "1.0", features = ["derive"] }

src/buf_pool.rs 🔗

@@ -1,26 +1,56 @@
 use crossbeam::queue::SegQueue;
 use std::ops::{Deref, DerefMut};
+use std::path::PathBuf;
 use std::sync::Arc;
 
-/// A thread-safe, lock-free pool of reusable byte buffers.
+/// Trait for types that can be pooled and reused.
+///
+/// Types implementing this trait can be:
+/// 1. Created empty via `Default`
+/// 2. Reset to empty state via `clear()` (preserving capacity)
+/// 3. Sent across threads (required by `SegQueue`)
+pub trait Poolable: Default + Send {
+    /// Reset the value to its empty state, preserving any allocated capacity.
+    fn clear(&mut self);
+}
+
+impl<T: Send> Poolable for Vec<T> {
+    fn clear(&mut self) {
+        Vec::clear(self);
+    }
+}
+
+impl Poolable for String {
+    fn clear(&mut self) {
+        String::clear(self);
+    }
+}
+
+impl Poolable for PathBuf {
+    fn clear(&mut self) {
+        PathBuf::clear(self);
+    }
+}
+
+/// A thread-safe, lock-free pool of reusable buffers.
 #[derive(Clone)]
-pub struct BufPool {
-    queue: Arc<SegQueue<Vec<u8>>>,
+pub struct BufPool<T: Poolable> {
+    queue: Arc<SegQueue<T>>,
 }
 
-impl BufPool {
+impl<T: Poolable> BufPool<T> {
     /// Creates a pool pre-populated with `capacity` empty buffers.
     pub fn with_capacity(capacity: usize) -> Self {
         let queue = Arc::new(SegQueue::new());
         for _ in 0..capacity {
-            queue.push(Vec::new());
+            queue.push(T::default());
         }
         Self { queue }
     }
 
     /// Checks out a buffer from the pool. If the pool is empty, allocates a new buffer.
-    pub fn checkout(&self) -> BufGuard {
-        let buf = self.queue.pop().unwrap_or_else(|| Vec::new());
+    pub fn checkout(&self) -> BufGuard<T> {
+        let buf = self.queue.pop().unwrap_or_else(T::default);
         BufGuard {
             buf,
             pool: self.queue.clone(),
@@ -29,15 +59,16 @@ impl BufPool {
 }
 
 /// RAII guard that automatically returns the buffer to the pool when dropped.
-pub struct BufGuard {
-    buf: Vec<u8>,
-    pool: Arc<SegQueue<Vec<u8>>>,
+#[derive(Debug)]
+pub struct BufGuard<T: Poolable> {
+    buf: T,
+    pool: Arc<SegQueue<T>>,
 }
 
-impl BufGuard {
+impl<T: Poolable> BufGuard<T> {
     /// Extracts the buffer, preventing automatic return to the pool.
     /// Useful if you need to move the buffer elsewhere.
-    pub fn into_inner(mut self) -> Vec<u8> {
+    pub fn into_inner(mut self) -> T {
         let buf = std::mem::take(&mut self.buf);
         // SAFETY:
         // 1) We forget `self`, preventing it from
@@ -51,7 +82,7 @@ impl BufGuard {
     }
 }
 
-impl Drop for BufGuard {
+impl<T: Poolable> Drop for BufGuard<T> {
     fn drop(&mut self) {
         let mut buf = std::mem::take(&mut self.buf);
         buf.clear();
@@ -59,15 +90,15 @@ impl Drop for BufGuard {
     }
 }
 
-impl Deref for BufGuard {
-    type Target = Vec<u8>;
+impl<T: Poolable> Deref for BufGuard<T> {
+    type Target = T;
 
     fn deref(&self) -> &Self::Target {
         &self.buf
     }
 }
 
-impl DerefMut for BufGuard {
+impl<T: Poolable> DerefMut for BufGuard<T> {
     fn deref_mut(&mut self) -> &mut Self::Target {
         &mut self.buf
     }

src/config.rs 🔗

@@ -1,5 +1,3 @@
-use anyhow::Context;
-use async_gen::{AsyncIter, r#gen};
 use clap::Parser;
 use rmcp::serde_json;
 use std::{
@@ -7,6 +5,8 @@ use std::{
     path::{Path, PathBuf},
 };
 
+use crate::buf_pool::BufPool;
+
 #[derive(Parser, Debug)]
 #[command(version, about)]
 pub struct CommandLineArgs {

src/lsp/client.rs 🔗

@@ -14,6 +14,7 @@ use futures::channel::oneshot;
 use tower::ServiceBuilder;
 
 use crate::config::Config;
+use crate::lsp::server::LSPServer;
 
 pub struct LSPClientState {
     pub config: Arc<Config>,
@@ -27,11 +28,11 @@ mod control_flow_state {
 pub struct LSPClient {
     child: Child,
     pub mainloop: MainLoop<Router<LSPClientState>>,
-    pub server: LSPServerSocket,
+    socket: LSPServerSocket,
 }
 
 impl LSPClient {
-    pub async fn setup(config: Arc<Config>) -> anyhow::Result<Self> {
+    pub async fn setup(config: Arc<Config>) -> anyhow::Result<(Self, LSPServer)> {
         let child = ProcessCommand::new(&config.lsp_server_command.command)
             .args(&config.lsp_server_command.args)
             .current_dir(&config.project_root)
@@ -99,11 +100,16 @@ impl LSPClient {
             .initialized(InitializedParams {})
             .expect("Bad response to Initialized message");
 
-        Ok(Self {
-            child,
-            mainloop,
-            server,
-        })
+        let lsp_server = LSPServer::new(server.clone());
+
+        Ok((
+            Self {
+                child,
+                mainloop,
+                socket: server,
+            },
+            lsp_server,
+        ))
     }
 
     pub async fn run_main_loop(mut self) -> anyhow::Result<()> {

src/lsp/mod.rs 🔗

@@ -1,3 +1,6 @@
 pub mod client;
+pub mod opened_documents;
+pub mod server;
 
 pub use client::LSPClient;
+pub use server::LSPServer;

src/lsp/opened_documents.rs 🔗

@@ -0,0 +1,131 @@
+use dashmap::DashMap;
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+use std::time::Instant;
+
+use crate::buf_pool::{BufGuard, BufPool};
+
+/// Represents an opened document tracked by the LSP client.
+#[derive(Debug)]
+pub struct OpenedDocument {
+    pub version: u32,
+    pub content: BufGuard<String>,
+    pub last_accessed: Instant,
+}
+
+/// Thread-safe tracker for opened LSP documents.
+///
+/// IMPORTANT: All paths passed to this type must be canonical to avoid
+/// duplicate entries (e.g., "/foo//bar" vs "/foo/bar").
+#[derive(Clone)]
+pub struct OpenedDocuments {
+    docs: Arc<DashMap<PathBuf, OpenedDocument>>,
+    string_pool: BufPool<String>,
+}
+
+impl OpenedDocuments {
+    pub fn new() -> Self {
+        Self {
+            docs: Arc::new(DashMap::new()),
+            string_pool: BufPool::with_capacity(50),
+        }
+    }
+
+    /// Validate that a path appears to be canonical.
+    /// A canonical path must be absolute and not contain `.` or `..` components.
+    fn validate_canonical(path: &Path) -> anyhow::Result<()> {
+        if !path.is_absolute() {
+            anyhow::bail!("Path must be canonical (absolute): {}", path.display());
+        }
+
+        // Check for . or .. components
+        for component in path.components() {
+            match component {
+                std::path::Component::CurDir | std::path::Component::ParentDir => {
+                    anyhow::bail!("Path must be canonical (no . or .. components): {}", path.display());
+                }
+                _ => {}
+            }
+        }
+
+        Ok(())
+    }
+
+    /// Check if a document is currently opened.
+    pub fn is_open(&self, path: impl AsRef<Path>) -> anyhow::Result<bool> {
+        let path = path.as_ref();
+        Self::validate_canonical(path)?;
+        Ok(self.docs.contains_key(path))
+    }
+
+    /// Get the version number of an opened document.
+    pub fn get_version(&self, path: impl AsRef<Path>) -> anyhow::Result<Option<u32>> {
+        let path = path.as_ref();
+        Self::validate_canonical(path)?;
+        Ok(self.docs.get(path).map(|doc| doc.version))
+    }
+
+    /// Insert a new opened document. Path must be canonical.
+    pub fn insert(&self, path: PathBuf, content: &str, version: u32) -> anyhow::Result<()> {
+        Self::validate_canonical(&path)?;
+        let mut buf = self.string_pool.checkout();
+        buf.push_str(content);
+        self.docs.insert(
+            path,
+            OpenedDocument {
+                version,
+                content: buf,
+                last_accessed: Instant::now(),
+            },
+        );
+        Ok(())
+    }
+
+    /// Update the last_accessed timestamp for a document.
+    pub fn touch(&self, path: impl AsRef<Path>) -> anyhow::Result<()> {
+        let path = path.as_ref();
+        Self::validate_canonical(path)?;
+        if let Some(mut doc) = self.docs.get_mut(path) {
+            doc.last_accessed = Instant::now();
+        }
+        Ok(())
+    }
+
+    /// Remove a document from tracking.
+    pub fn remove(&self, path: impl AsRef<Path>) -> anyhow::Result<Option<OpenedDocument>> {
+        let path = path.as_ref();
+        Self::validate_canonical(path)?;
+        Ok(self.docs.remove(path).map(|(_, doc)| doc))
+    }
+
+    /// Evict the least recently used documents, keeping only `keep_count` documents.
+    /// Returns the paths of evicted documents.
+    pub fn evict_lru(&self, keep_count: usize) -> Vec<PathBuf> {
+        let current_count = self.docs.len();
+        if current_count <= keep_count {
+            return Vec::new();
+        }
+
+        let evict_count = current_count - keep_count;
+
+        // Collect all documents with their access times
+        let mut docs_with_time: Vec<(PathBuf, Instant)> = self
+            .docs
+            .iter()
+            .map(|entry| (entry.key().clone(), entry.value().last_accessed))
+            .collect();
+
+        // Sort by last_accessed (oldest first)
+        docs_with_time.sort_by_key(|(_, time)| *time);
+
+        // Evict the oldest documents
+        let mut evicted = Vec::new();
+        for (path, _) in docs_with_time.into_iter().take(evict_count) {
+            if self.docs.remove(&path).is_some() {
+                evicted.push(path);
+            }
+        }
+
+        evicted
+    }
+}

src/lsp/server.rs 🔗

@@ -0,0 +1,272 @@
+use async_lsp::lsp_types::{
+    DidCloseTextDocumentParams, DidOpenTextDocumentParams, DocumentDiagnosticParams,
+    DocumentSymbolParams, DocumentSymbolResponse, TextDocumentIdentifier, TextDocumentItem,
+    Url,
+};
+use async_lsp::{LanguageServer, ServerSocket as LSPServerSocket};
+use rmcp::ErrorData as MCPError;
+use std::path::Path;
+
+use crate::lsp::opened_documents::OpenedDocuments;
+
+const MAX_OPEN_DOCUMENTS: usize = 50;
+
+/// Language identifiers for LSP textDocument/didOpen.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum LanguageId {
+    Rust,
+    Python,
+    JavaScript,
+    TypeScript,
+    JavaScriptReact,
+    TypeScriptReact,
+    Go,
+    Java,
+    C,
+    Cpp,
+    CSharp,
+    Ruby,
+    Php,
+    Swift,
+    Kotlin,
+    Scala,
+    Html,
+    Css,
+    Scss,
+    Less,
+    Json,
+    Xml,
+    Yaml,
+    Toml,
+    Markdown,
+    Shell,
+    /// For languages not explicitly enumerated or unknown file types.
+    Other(String),
+}
+
+impl LanguageId {
+    /// Infer language ID from file path extension.
+    pub fn from_path(path: &Path) -> Self {
+        match path.extension().and_then(|e| e.to_str()) {
+            Some("rs") => Self::Rust,
+            Some("py") | Some("pyi") => Self::Python,
+            Some("js") | Some("mjs") | Some("cjs") => Self::JavaScript,
+            Some("ts") | Some("mts") | Some("cts") => Self::TypeScript,
+            Some("jsx") => Self::JavaScriptReact,
+            Some("tsx") => Self::TypeScriptReact,
+            Some("go") => Self::Go,
+            Some("java") => Self::Java,
+            Some("c") | Some("h") => Self::C,
+            Some("cpp") | Some("cc") | Some("cxx") | Some("hpp") | Some("hh") | Some("hxx") => Self::Cpp,
+            Some("cs") => Self::CSharp,
+            Some("rb") => Self::Ruby,
+            Some("php") => Self::Php,
+            Some("swift") => Self::Swift,
+            Some("kt") | Some("kts") => Self::Kotlin,
+            Some("scala") | Some("sc") => Self::Scala,
+            Some("html") | Some("htm") => Self::Html,
+            Some("css") => Self::Css,
+            Some("scss") => Self::Scss,
+            Some("less") => Self::Less,
+            Some("json") | Some("jsonc") => Self::Json,
+            Some("xml") => Self::Xml,
+            Some("yaml") | Some("yml") => Self::Yaml,
+            Some("toml") => Self::Toml,
+            Some("md") | Some("markdown") => Self::Markdown,
+            Some("sh") | Some("bash") | Some("zsh") => Self::Shell,
+            _ => Self::Other("plaintext".into()),
+        }
+    }
+
+    /// Convert to LSP-compliant language identifier string.
+    pub fn as_lsp_identifier(&self) -> &str {
+        match self {
+            Self::Rust => "rust",
+            Self::Python => "python",
+            Self::JavaScript => "javascript",
+            Self::TypeScript => "typescript",
+            Self::JavaScriptReact => "javascriptreact",
+            Self::TypeScriptReact => "typescriptreact",
+            Self::Go => "go",
+            Self::Java => "java",
+            Self::C => "c",
+            Self::Cpp => "cpp",
+            Self::CSharp => "csharp",
+            Self::Ruby => "ruby",
+            Self::Php => "php",
+            Self::Swift => "swift",
+            Self::Kotlin => "kotlin",
+            Self::Scala => "scala",
+            Self::Html => "html",
+            Self::Css => "css",
+            Self::Scss => "scss",
+            Self::Less => "less",
+            Self::Json => "json",
+            Self::Xml => "xml",
+            Self::Yaml => "yaml",
+            Self::Toml => "toml",
+            Self::Markdown => "markdown",
+            Self::Shell => "shellscript",
+            Self::Other(s) => s.as_str(),
+        }
+    }
+}
+
+/// Wrapper around LSPServerSocket that manages document lifecycle.
+#[derive(Clone)]
+pub struct LSPServer {
+    socket: LSPServerSocket,
+    opened_docs: OpenedDocuments,
+}
+
+impl LSPServer {
+    pub fn new(socket: LSPServerSocket) -> Self {
+        Self {
+            socket,
+            opened_docs: OpenedDocuments::new(),
+        }
+    }
+
+    /// Open a document with the LSP server. Path must be canonical.
+    /// If the document is already open, this is a no-op (but updates last_accessed).
+    pub async fn open_document(&self, path: impl AsRef<Path>, content: &str) -> Result<(), MCPError> {
+        let path = path.as_ref();
+
+        // Check if already open
+        if self.opened_docs.is_open(path)
+            .map_err(|e| MCPError::internal_error(e.to_string(), None))?
+        {
+            self.opened_docs.touch(path)
+                .map_err(|e| MCPError::internal_error(e.to_string(), None))?;
+            return Ok(());
+        }
+
+        // Infer language from file extension
+        let language_id = LanguageId::from_path(path);
+
+        // Convert path to URI
+        let uri = Url::from_file_path(path)
+            .map_err(|_| MCPError::invalid_params(format!("Invalid file path: {}", path.display()), None))?;
+
+        // Send didOpen notification
+        self.socket
+            .clone()
+            .did_open(DidOpenTextDocumentParams {
+                text_document: TextDocumentItem {
+                    uri: uri.clone(),
+                    language_id: language_id.as_lsp_identifier().to_string(),
+                    version: 0,
+                    text: content.to_string(),
+                },
+            })
+            .map_err(|e| MCPError::internal_error(format!("Failed to send didOpen: {}", e), None))?;
+
+        // Track the document
+        self.opened_docs
+            .insert(path.to_path_buf(), content, 0)
+            .map_err(|e| MCPError::internal_error(e.to_string(), None))?;
+
+        // Evict old documents if needed
+        self.evict_old_documents().await?;
+
+        Ok(())
+    }
+
+    /// Close a document with the LSP server. Path must be canonical.
+    pub async fn close_document(&self, path: impl AsRef<Path>) -> Result<(), MCPError> {
+        let path = path.as_ref();
+
+        // Convert path to URI
+        let uri = Url::from_file_path(path)
+            .map_err(|_| MCPError::invalid_params(format!("Invalid file path: {}", path.display()), None))?;
+
+        // Send didClose notification
+        self.socket
+            .clone()
+            .did_close(DidCloseTextDocumentParams {
+                text_document: TextDocumentIdentifier { uri },
+            })
+            .map_err(|e| MCPError::internal_error(format!("Failed to send didClose: {}", e), None))?;
+
+        // Remove from tracking
+        self.opened_docs
+            .remove(path)
+            .map_err(|e| MCPError::internal_error(e.to_string(), None))?;
+
+        Ok(())
+    }
+
+    /// Evict least recently used documents to keep count under MAX_OPEN_DOCUMENTS.
+    async fn evict_old_documents(&self) -> Result<(), MCPError> {
+        let evicted = self.opened_docs.evict_lru(MAX_OPEN_DOCUMENTS);
+
+        for path in evicted {
+            // Close with LSP server
+            let uri = Url::from_file_path(&path)
+                .map_err(|_| MCPError::internal_error(format!("Invalid file path: {}", path.display()), None))?;
+
+            self.socket
+                .clone()
+                .did_close(DidCloseTextDocumentParams {
+                    text_document: TextDocumentIdentifier { uri },
+                })
+                .map_err(|e| MCPError::internal_error(format!("Failed to send didClose during eviction: {}", e), None))?;
+        }
+
+        Ok(())
+    }
+
+    /// Get diagnostics for a document. Path must be canonical.
+    pub async fn document_diagnostic(
+        &self,
+        path: impl AsRef<Path>,
+    ) -> Result<async_lsp::lsp_types::DocumentDiagnosticReportResult, MCPError> {
+        let path = path.as_ref();
+
+        // Touch to update LRU
+        self.opened_docs
+            .touch(path)
+            .map_err(|e| MCPError::internal_error(e.to_string(), None))?;
+
+        let uri = Url::from_file_path(path)
+            .map_err(|_| MCPError::invalid_params(format!("Invalid file path: {}", path.display()), None))?;
+
+        self.socket
+            .clone()
+            .document_diagnostic(DocumentDiagnosticParams {
+                text_document: TextDocumentIdentifier { uri },
+                identifier: None,
+                previous_result_id: None,
+                work_done_progress_params: Default::default(),
+                partial_result_params: Default::default(),
+            })
+            .await
+            .map_err(|e| MCPError::internal_error(format!("Failed to get diagnostics: {}", e), None))
+    }
+
+    /// Get document symbols (outline). Path must be canonical.
+    pub async fn document_symbols(
+        &self,
+        path: impl AsRef<Path>,
+    ) -> Result<Option<DocumentSymbolResponse>, MCPError> {
+        let path = path.as_ref();
+
+        // Touch to update LRU
+        self.opened_docs
+            .touch(path)
+            .map_err(|e| MCPError::internal_error(e.to_string(), None))?;
+
+        let uri = Url::from_file_path(path)
+            .map_err(|_| MCPError::invalid_params(format!("Invalid file path: {}", path.display()), None))?;
+
+        self.socket
+            .clone()
+            .document_symbol(DocumentSymbolParams {
+                text_document: TextDocumentIdentifier { uri },
+                work_done_progress_params: Default::default(),
+                partial_result_params: Default::default(),
+            })
+            .await
+            .map_err(|e| MCPError::internal_error(format!("Failed to get document symbols: {}", e), None))
+    }
+}

src/mcp/server.rs 🔗

@@ -9,30 +9,28 @@ use rmcp::model::CallToolResult;
 use rmcp::model::{Implementation, ProtocolVersion, ServerCapabilities, ServerInfo};
 use rmcp::{tool_handler, tool_router};
 
-use async_lsp::ServerSocket as LSPServerSocket;
-
 use crate::buf_pool::BufPool;
 use crate::config::Config;
-use crate::lsp::LSPClient;
+use crate::lsp::{LSPClient, LSPServer};
 pub use crate::mcp::tools::read::*;
 
 pub struct MCPServer {
     pub(crate) tool_router: ToolRouter<Self>,
     pub(crate) config: Arc<Config>,
-    pub(crate) lsp_server: LSPServerSocket,
-    pub(crate) buf_pool: BufPool,
+    pub(crate) lsp_server: LSPServer,
+    pub(crate) buf_pool: BufPool<Vec<u8>>,
 }
 
 pub async fn setup(config: Arc<Config>) -> anyhow::Result<(MCPServer, LSPClient)> {
-    let lsp_client = LSPClient::setup(config.clone()).await?;
+    let (lsp_client, lsp_server) = LSPClient::setup(config.clone()).await?;
 
-    let server = MCPServer::new(config, lsp_client.server.clone());
+    let server = MCPServer::new(config, lsp_server);
 
     Ok((server, lsp_client))
 }
 
 impl MCPServer {
-    pub fn new(config: Arc<Config>, lsp_server: LSPServerSocket) -> Self {
+    pub fn new(config: Arc<Config>, lsp_server: LSPServer) -> Self {
         Self {
             config,
             lsp_server,

src/mcp/tools/read.rs 🔗

@@ -9,6 +9,7 @@ use async_lsp::lsp_types::{
 };
 use rmcp::ErrorData as MCPError;
 use rmcp::{handler::server::wrapper::Parameters, model::CallToolResult, schemars, serde_json};
+use tokio::io::AsyncReadExt; // for read_to_end()
 
 use crate::mcp::*;
 
@@ -38,6 +39,12 @@ pub async fn call(
             MCPError::invalid_params(format!("Path not in project: {:?}", &args.path), None)
         })?;
 
+    let file = tokio::fs::File::open(file_path.as_path())
+        .await
+        .map_err(|e| MCPError::internal_error(format!("Failed to open file: {e:?}"), None))?;
+
+    let file_buf = server.buf_pool.checkout();
+
     Err(MCPError::internal_error("Not yet implemented", None))
     // let content = tokio::fs::read_to_string(&file_path)
     //     .await