Detailed changes
@@ -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
@@ -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)
+ }
+}
@@ -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))
+}
@@ -0,0 +1,3 @@
+pub mod client;
+
+pub use client::setup_lsp_client;
@@ -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())
@@ -0,0 +1,4 @@
+pub mod server;
+pub mod tools;
+
+pub use server::{MCPServer, MCPServerConfig};
@@ -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())
+ }
+ }
+}
@@ -0,0 +1 @@
+pub mod read;
@@ -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,
+ })
+}