modular!

Phillip Davis created

Change summary

AGENTS.md             | 122 ++++++++++++++++++++++++
src/config.rs         |  30 ++++++
src/lsp/client.rs     |  87 +++++++++++++++++
src/lsp/mod.rs        |   3 
src/main.rs           | 221 +++-----------------------------------------
src/mcp/mod.rs        |   4 
src/mcp/server.rs     |  59 ++++++++++++
src/mcp/tools/mod.rs  |   1 
src/mcp/tools/read.rs |  65 +++++++++++++
9 files changed, 389 insertions(+), 203 deletions(-)

Detailed changes

AGENTS.md 🔗

@@ -0,0 +1,122 @@
+# AGENTS.md
+
+This file provides guidance to AI coding agents when working with code in this repository.
+
+## Common Commands
+
+```bash
+# Build the project
+cargo build
+
+# Build in release mode
+cargo build --release
+
+# Run the project (requires a config file)
+cargo run -- --config sample_config.json
+
+# Run tests
+cargo test
+
+# Run a specific test
+cargo test <test_name>
+
+# Check for compilation errors without building
+cargo check
+
+# Format code
+cargo fmt
+
+# Run clippy linter
+cargo clippy
+```
+
+## Architecture Overview
+
+**lsp2mcp** is a bridge that translates arbitrary LSP (Language Server Protocol) servers into MCP (Model Context Protocol) servers, enabling AI coding agents to receive real-time diagnostics and ground truth about the code they write.
+
+### Three-Layer Architecture
+
+The application operates with three concurrent layers:
+
+1. **MCP Server Layer** (`MCPServer` struct)
+   - Exposes tools to AI agents via MCP protocol
+   - Runs on stdio transport (reads from stdin, writes to stdout)
+   - Currently implements a `read` tool that returns file content + LSP diagnostics
+
+2. **LSP Client Layer** (`async_lsp::MainLoop`)
+   - Acts as an LSP client to communicate with external LSP servers
+   - Spawns the LSP server process as a subprocess (stdin/stdout pipes)
+   - Handles LSP notifications (Progress, PublishDiagnostics, ShowMessage)
+   - Router-based event handling with `LSPClientState`
+
+3. **LSP Server Process** (spawned subprocess)
+   - External process (e.g., rust-analyzer, typescript-language-server)
+   - Configured via JSON config file (`sample_config.json`)
+   - Killed automatically when parent process exits (`kill_on_drop: true`)
+
+### Execution Flow
+
+```
+AI Agent (MCP Client)
+    ↓ stdio
+MCPServer::read() tool
+    ↓ LSPServerSocket::document_diagnostic()
+async_lsp MainLoop (LSP Client)
+    ↓ subprocess stdin/stdout pipes
+LSP Server Process (e.g., rust-analyzer)
+```
+
+### Critical Concurrency Detail
+
+The application uses `futures::join!()` to run two async tasks concurrently:
+- `mainloop_fut`: The LSP client mainloop reading/writing from LSP server subprocess
+- `server.waiting()`: The MCP server awaiting requests from AI agents
+
+Both must run simultaneously because MCP tool calls need to send LSP requests and await responses.
+
+### Configuration
+
+The config file (`sample_config.json`) specifies:
+- `lsp_server_command`: Command and args to spawn the LSP server subprocess
+- `project_root`: Workspace root directory (passed to LSP server initialization and used for resolving relative file paths)
+
+File paths in tool arguments are relative to `project_root` and are canonicalized before use.
+
+### Key Framework Usage Patterns
+
+**rmcp (MCP server framework):**
+- Use `#[tool_router]` macro on impl blocks to register tools
+- Use `#[tool(description = "...")]` macro on methods to expose them as MCP tools
+- Tool arguments must be deserializable (`serde::Deserialize`) and have JSON schema (`schemars::JsonSchema`)
+- Extract parameters with `Parameters<T>` wrapper
+- Return `Result<CallToolResult, MCPError>`
+
+**async-lsp (LSP client framework):**
+- Use `Router::new()` with a state type (`LSPClientState`)
+- Chain `.notification::<NotificationType>(handler)` to handle LSP notifications
+- Handlers return `ControlFlow::Continue(())` or `ControlFlow::Break(result)`
+- The `MainLoop::new_client()` creates both the mainloop runner and the `ServerSocket` client handle
+- `ServerSocket` is cloneable and can be shared across async contexts (used in `MCPServer`)
+
+### Current Limitations and TODOs
+
+- Only the `read` tool is implemented; other tools like `edit`, `complete`, `definition`, etc. are not yet implemented
+- Diagnostics are fetched on-demand via `document_diagnostic` (pull-based), not via `PublishDiagnostics` notifications (push-based)
+- Commented-out `did_open` code suggests file opening/tracking may be needed for some LSP servers
+- Unused variables and dead code indicate work-in-progress state (see compiler warnings)
+
+### Adding New MCP Tools
+
+To add a new tool that leverages LSP capabilities:
+
+1. Add a method to the `#[tool_router] impl MCPServer` block
+2. Define argument and output structs with `serde::Deserialize` + `schemars::JsonSchema`
+3. Use `self.lsp_server.clone().<lsp_method>()` to call LSP methods
+4. Construct and return `CallToolResult` with diagnostics/results in `structured_content`
+
+Example LSP methods available via `LSPServerSocket`:
+- `document_diagnostic()` - Get diagnostics for a file
+- `completion()` - Get code completions
+- `goto_definition()` - Find symbol definitions
+- `hover()` - Get hover information
+- `did_open()`, `did_change()`, `did_close()` - Notify file changes

src/config.rs 🔗

@@ -0,0 +1,30 @@
+use clap::Parser;
+use rmcp::serde_json;
+use std::path::PathBuf;
+
+#[derive(Parser, Debug)]
+#[command(version, about)]
+pub struct CommandLineArgs {
+    #[arg(short, long, value_name = "FILE")]
+    pub config: PathBuf,
+}
+
+#[derive(serde::Deserialize, Debug)]
+pub struct Command {
+    pub command: String,
+    pub args: Vec<String>,
+}
+
+#[derive(serde::Deserialize, Debug)]
+pub struct Config {
+    pub lsp_server_command: Command,
+    pub project_root: PathBuf,
+}
+
+impl Config {
+    pub async fn load(path: &PathBuf) -> Result<Self, Box<dyn std::error::Error>> {
+        let config_content = tokio::fs::read_to_string(path).await?;
+        let config: Config = serde_json::from_str(&config_content)?;
+        Ok(config)
+    }
+}

src/lsp/client.rs 🔗

@@ -0,0 +1,87 @@
+use std::ops::ControlFlow;
+use std::path::PathBuf;
+
+use async_lsp::lsp_types::notification::{Progress, PublishDiagnostics, ShowMessage};
+use async_lsp::lsp_types::{
+    ClientCapabilities, DiagnosticClientCapabilities, InitializeParams, InitializedParams,
+    TextDocumentClientCapabilities, Url, WindowClientCapabilities, WorkspaceFolder,
+};
+use async_lsp::router::Router;
+use async_lsp::{LanguageServer, ServerSocket as LSPServerSocket};
+use async_process::{Command as ProcessCommand, Stdio};
+use futures::channel::oneshot;
+use tower::ServiceBuilder;
+
+pub struct LSPClientState {
+    pub indexed_tx: Option<oneshot::Sender<()>>,
+}
+
+mod control_flow_state {
+    pub struct Stop;
+}
+
+pub async fn setup_lsp_client(
+    lsp_command: &str,
+    lsp_args: &[String],
+    project_root: &PathBuf,
+) -> Result<(tokio::task::JoinHandle<()>, LSPServerSocket), Box<dyn std::error::Error>> {
+    let mut child = ProcessCommand::new(lsp_command)
+        .args(lsp_args)
+        .current_dir(project_root)
+        .stdin(Stdio::piped())
+        .stdout(Stdio::piped())
+        .stderr(Stdio::inherit())
+        .kill_on_drop(true)
+        .spawn()
+        .expect(format!("Failed to start lsp: {} {:?}", lsp_command, lsp_args).as_ref());
+
+    let stdin = child.stdin.take().unwrap();
+    let stdout = child.stdout.take().unwrap();
+
+    let (mainloop, mut server) = async_lsp::MainLoop::new_client(|_server| {
+        let mut router = Router::new(LSPClientState { indexed_tx: None });
+
+        router
+            .notification::<Progress>(|_this, _params| ControlFlow::Continue(()))
+            .notification::<PublishDiagnostics>(|_this, _params| ControlFlow::Continue(()))
+            .notification::<ShowMessage>(|_this, _params| ControlFlow::Continue(()))
+            .event(|_, _: control_flow_state::Stop| ControlFlow::Break(Ok(())));
+
+        ServiceBuilder::new().service(router)
+    });
+
+    let mainloop_fut = tokio::spawn(async move {
+        mainloop.run_buffered(stdout, stdin).await.unwrap();
+    });
+
+    let project_root_canonicalized = tokio::fs::canonicalize(project_root)
+        .await
+        .expect("Failed to canonicalize project root");
+    let uri = Url::from_file_path(&project_root_canonicalized).unwrap();
+    let _init_ret = server
+        .initialize(InitializeParams {
+            workspace_folders: Some(vec![WorkspaceFolder {
+                uri: uri,
+                name: "root".into(),
+            }]),
+            capabilities: ClientCapabilities {
+                window: Some(WindowClientCapabilities {
+                    work_done_progress: Some(true),
+                    ..WindowClientCapabilities::default()
+                }),
+                text_document: Some(TextDocumentClientCapabilities {
+                    diagnostic: Some(DiagnosticClientCapabilities {
+                        ..DiagnosticClientCapabilities::default()
+                    }),
+                    ..TextDocumentClientCapabilities::default()
+                }),
+                ..ClientCapabilities::default()
+            },
+            ..InitializeParams::default()
+        })
+        .await
+        .unwrap();
+    server.initialized(InitializedParams {}).unwrap();
+
+    Ok((mainloop_fut, server))
+}

src/lsp/mod.rs 🔗

@@ -0,0 +1,3 @@
+pub mod client;
+
+pub use client::setup_lsp_client;

src/main.rs 🔗

@@ -1,222 +1,37 @@
-use std::ops::ControlFlow;
-use std::path::PathBuf;
+mod config;
+mod lsp;
+mod mcp;
 
-use async_lsp::lsp_types::notification::{Progress, PublishDiagnostics, ShowMessage};
-use async_lsp::lsp_types::{
-    ClientCapabilities, DiagnosticClientCapabilities, DocumentDiagnosticParams,
-    DocumentDiagnosticReportResult, InitializeParams, InitializedParams, PartialResultParams,
-    TextDocumentClientCapabilities, TextDocumentIdentifier, Url, WindowClientCapabilities,
-    WorkDoneProgressParams, WorkspaceFolder,
-};
-use async_lsp::router::Router;
-use async_lsp::{LanguageServer, ServerSocket as LSPServerSocket};
-use async_process::{Command as ProcessCommand, Stdio};
 use clap::Parser;
-use futures::channel::oneshot;
 use futures::join;
-use rmcp::handler::server::tool::ToolRouter;
-use rmcp::handler::server::wrapper::Parameters;
-use rmcp::model::{
-    CallToolResult, Implementation, ProtocolVersion, ServerCapabilities, ServerInfo,
-};
-use rmcp::{ErrorData as MCPError, ServiceExt, schemars};
-use rmcp::{ServerHandler as MCPServerHandler, serde_json};
-use rmcp::{tool, tool_handler, tool_router};
-use tower::ServiceBuilder;
+use rmcp::ServiceExt;
 
-struct LSPClientState {
-    indexed_tx: Option<oneshot::Sender<()>>,
-}
-
-mod ControlFlowState {
-    pub struct Stop;
-}
-
-#[derive(Clone)]
-struct MCPServerConfig {
-    project_root: PathBuf,
-}
-
-#[derive(Clone)]
-struct MCPServer {
-    tool_router: ToolRouter<Self>,
-    config: MCPServerConfig,
-    lsp_server: LSPServerSocket,
-}
-
-#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
-struct ReadToolArgs {
-    path: PathBuf,
-    range_start: usize,
-    range_end: usize,
-}
-
-#[derive(Debug, serde::Serialize)]
-struct ReadToolOutput {
-    content: Option<String>,
-    diagnostics: DocumentDiagnosticReportResult,
-}
-
-#[tool_router]
-impl MCPServer {
-    /// Description take from Amp's description of their built-in `read` tool
-    #[tool(
-        description = "Like the 'view' tool, except it returns LSP diagnostics too. Always use this instead of 'view'"
-    )]
-    async fn read(
-        &self,
-        Parameters(args): Parameters<ReadToolArgs>,
-    ) -> Result<CallToolResult, MCPError> {
-        let file_path = self.config.project_root.join(&args.path);
-        let file_path = tokio::fs::canonicalize(file_path).await.unwrap();
-        let content = tokio::fs::read_to_string(&file_path)
-            .await
-            .map_err(|e| MCPError::invalid_request(format!("Failed to read file: {e}"), None))?;
-
-        let diagnostic_report = self
-            .lsp_server
-            .clone()
-            .document_diagnostic(DocumentDiagnosticParams {
-                text_document: TextDocumentIdentifier::new(Url::from_file_path(file_path).unwrap()),
-                identifier: None,
-                previous_result_id: None,
-                work_done_progress_params: WorkDoneProgressParams {
-                    work_done_token: None,
-                },
-                partial_result_params: PartialResultParams {
-                    partial_result_token: None,
-                },
-            })
-            .await
-            .unwrap();
-
-        // self.lsp_server.clone().did_open(DidOpenTextDocumentParams {
-        // 	text_document: TextDocumentItem {
-        // 		uri: Url::from_file_path(file_path).unwrap(),
-        // 		language_id: "rust".into(),
-        // 		version: 0,
-        // 		text: content
-        // 	}
-        // }).unwrap();
-
-        // TODO: Construct and return result
-        Ok(CallToolResult {
-            content: vec![],
-            structured_content: Some(serde_json::json!(ReadToolOutput {
-                content: Some(content),
-                diagnostics: diagnostic_report
-            })),
-            is_error: None,
-            meta: None,
-        })
-    }
-}
-
-#[tool_handler]
-impl MCPServerHandler for MCPServer {
-    fn get_info(&self) -> ServerInfo {
-        ServerInfo {
-			protocol_version: ProtocolVersion::V_2024_11_05,
-			capabilities: ServerCapabilities::builder().enable_tools().build(),
-			server_info: Implementation::from_build_env(),
-			instructions: Some("This server turns standard coding agent tools like `read` and `edit` into LSP clients.".into())
-		}
-    }
-}
-
-#[derive(clap::Parser, Debug)]
-#[command(version, about)]
-struct CommandLineArgs {
-    #[arg(short, long, value_name = "FILE")]
-    config: PathBuf,
-}
-
-#[derive(serde::Deserialize, Debug)]
-struct Command {
-    command: String,
-    args: Vec<String>,
-}
-
-#[derive(serde::Deserialize, Debug)]
-struct Config {
-    lsp_server_command: Command,
-    project_root: PathBuf,
-}
+use config::{CommandLineArgs, Config};
+use lsp::setup_lsp_client;
+use mcp::{MCPServer, MCPServerConfig};
 
 #[tokio::main]
 async fn main() -> Result<(), Box<dyn std::error::Error>> {
     let args = CommandLineArgs::parse();
+    let config = Config::load(&args.config).await?;
 
-    let config_content = tokio::fs::read_to_string(&args.config).await?;
-
-    let config: Config =
-        serde_json::from_str(&config_content).expect("Failed to parse config file");
-
-    let mut child = ProcessCommand::new(&config.lsp_server_command.command)
-        .args(&config.lsp_server_command.args)
-        .current_dir(&config.project_root)
-        .stdin(Stdio::piped())
-        .stdout(Stdio::piped())
-        .stderr(Stdio::inherit())
-        .kill_on_drop(true)
-        .spawn()
-        .expect(format!("Failed to start lsp: {:?}", &config.lsp_server_command).as_ref());
-
-    let stdin = child.stdin.take().unwrap();
-    let stdout = child.stdout.take().unwrap();
-
-    let (mainloop, mut server) = async_lsp::MainLoop::new_client(|server| {
-        let mut router = Router::new(LSPClientState { indexed_tx: None });
-
-        router
-            .notification::<Progress>(|this, params| ControlFlow::Continue(()))
-            .notification::<PublishDiagnostics>(|this, params| ControlFlow::Continue(()))
-            .notification::<ShowMessage>(|this, params| ControlFlow::Continue(()))
-            .event(|_, _: ControlFlowState::Stop| ControlFlow::Break(Ok(())));
-
-        ServiceBuilder::new().service(router)
-    }); // Initialize.
-
-    let mainloop_fut = tokio::spawn(async move {
-        mainloop.run_buffered(stdout, stdin).await.unwrap();
-    });
+    let (mainloop_fut, lsp_server) = setup_lsp_client(
+        &config.lsp_server_command.command,
+        &config.lsp_server_command.args,
+        &config.project_root,
+    )
+    .await?;
 
     let project_root_canonicalized = tokio::fs::canonicalize(config.project_root)
         .await
         .expect("Failed to canonicalize project root");
-    let uri = Url::from_file_path(&project_root_canonicalized).unwrap();
-    let init_ret = server
-        .initialize(InitializeParams {
-            workspace_folders: Some(vec![WorkspaceFolder {
-                uri: uri,
-                name: "root".into(),
-            }]),
-            capabilities: ClientCapabilities {
-                window: Some(WindowClientCapabilities {
-                    work_done_progress: Some(true),
-                    ..WindowClientCapabilities::default()
-                }),
-                text_document: Some(TextDocumentClientCapabilities {
-                    diagnostic: Some(DiagnosticClientCapabilities {
-                        ..DiagnosticClientCapabilities::default()
-                    }),
-                    ..TextDocumentClientCapabilities::default()
-                }),
-                ..ClientCapabilities::default()
-            },
-            ..InitializeParams::default()
-        })
-        .await
-        .unwrap();
-    server.initialized(InitializedParams {}).unwrap();
 
-    let mcp_service = MCPServer {
-        config: MCPServerConfig {
+    let mcp_service = MCPServer::new(
+        MCPServerConfig {
             project_root: project_root_canonicalized,
         },
-        tool_router: MCPServer::tool_router(),
-        lsp_server: server,
-    };
+        lsp_server,
+    );
 
     let server = mcp_service
         .serve(rmcp::transport::stdio())

src/mcp/mod.rs 🔗

@@ -0,0 +1,4 @@
+pub mod server;
+pub mod tools;
+
+pub use server::{MCPServer, MCPServerConfig};

src/mcp/server.rs 🔗

@@ -0,0 +1,59 @@
+use std::path::PathBuf;
+
+use async_lsp::ServerSocket as LSPServerSocket;
+use rmcp::model::CallToolResult;
+use rmcp::ServerHandler as MCPServerHandler;
+use rmcp::ErrorData as MCPError;
+use rmcp::handler::server::tool::ToolRouter;
+use rmcp::handler::server::wrapper::Parameters;
+use rmcp::model::{Implementation, ProtocolVersion, ServerCapabilities, ServerInfo};
+use rmcp::{tool_handler, tool_router};
+
+pub use crate::mcp::tools::read::*;
+
+#[derive(Clone)]
+pub struct MCPServerConfig {
+    pub project_root: PathBuf,
+}
+
+#[derive(Clone)]
+pub struct MCPServer {
+    pub(crate) tool_router: ToolRouter<Self>,
+    pub(crate) config: MCPServerConfig,
+    pub(crate) lsp_server: LSPServerSocket,
+}
+
+impl MCPServer {
+    pub fn new(config: MCPServerConfig, lsp_server: LSPServerSocket) -> Self {
+        Self {
+            config,
+            lsp_server,
+            tool_router: Self::tool_router(),
+        }
+    }
+}
+
+#[tool_router]
+impl MCPServer {
+    #[rmcp::tool(
+        description = "Like the 'view' tool, except it returns LSP diagnostics too. Always use this instead of 'view'"
+    )]
+    pub async fn read(
+        &self,
+        Parameters(args): Parameters<ReadToolArgs>,
+    ) -> Result<CallToolResult, MCPError> {
+        crate::mcp::tools::read::call(self, Parameters(args)).await
+	}
+}
+
+#[tool_handler]
+impl MCPServerHandler for MCPServer {
+    fn get_info(&self) -> ServerInfo {
+        ServerInfo {
+            protocol_version: ProtocolVersion::V_2024_11_05,
+            capabilities: ServerCapabilities::builder().enable_tools().build(),
+            server_info: Implementation::from_build_env(),
+            instructions: Some("This server turns standard coding agent tools like `read` and `edit` into LSP clients.".into())
+        }
+    }
+}

src/mcp/tools/read.rs 🔗

@@ -0,0 +1,65 @@
+use std::path::PathBuf;
+
+use async_lsp::LanguageServer;
+use async_lsp::lsp_types::{
+    DocumentDiagnosticParams, DocumentDiagnosticReportResult, PartialResultParams,
+    TextDocumentIdentifier, Url, WorkDoneProgressParams,
+};
+use rmcp::ErrorData as MCPError;
+use rmcp::{
+    handler::server::wrapper::Parameters, model::CallToolResult, schemars, serde_json, tool_router,
+};
+
+use crate::mcp::*;
+use rmcp::ServerHandler as MCPServerHandler;
+
+#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
+pub struct ReadToolArgs {
+    pub path: PathBuf,
+    pub range_start: usize,
+    pub range_end: usize,
+}
+
+#[derive(Debug, serde::Serialize)]
+pub struct ReadToolOutput {
+    pub content: Option<String>,
+    pub diagnostics: DocumentDiagnosticReportResult,
+}
+
+pub async fn call(
+    server: &MCPServer,
+    Parameters(args): Parameters<ReadToolArgs>,
+) -> Result<CallToolResult, MCPError> {
+    let file_path = server.config.project_root.join(&args.path);
+    let file_path = tokio::fs::canonicalize(file_path).await.unwrap();
+    let content = tokio::fs::read_to_string(&file_path)
+        .await
+        .map_err(|e| MCPError::invalid_request(format!("Failed to read file: {e}"), None))?;
+
+    let mut lsp_server = server.lsp_server.clone();
+
+    let diagnostic_report = lsp_server
+        .document_diagnostic(DocumentDiagnosticParams {
+            text_document: TextDocumentIdentifier::new(Url::from_file_path(file_path).unwrap()),
+            identifier: None,
+            previous_result_id: None,
+            work_done_progress_params: WorkDoneProgressParams {
+                work_done_token: None,
+            },
+            partial_result_params: PartialResultParams {
+                partial_result_token: None,
+            },
+        })
+        .await
+        .unwrap();
+
+    Ok(CallToolResult {
+        content: vec![],
+        structured_content: Some(serde_json::json!(ReadToolOutput {
+            content: Some(content),
+            diagnostics: diagnostic_report
+        })),
+        is_error: None,
+        meta: None,
+    })
+}