diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..bae2cf70ba9aea33b4a5ebdca5d9231220205a1c --- /dev/null +++ b/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 + +# 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` wrapper +- Return `Result` + +**async-lsp (LSP client framework):** +- Use `Router::new()` with a state type (`LSPClientState`) +- Chain `.notification::(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().()` 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 diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000000000000000000000000000000000000..6f7465b61f0130913849ff0cdb247fe30c85bc92 --- /dev/null +++ b/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, +} + +#[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> { + let config_content = tokio::fs::read_to_string(path).await?; + let config: Config = serde_json::from_str(&config_content)?; + Ok(config) + } +} diff --git a/src/lsp/client.rs b/src/lsp/client.rs new file mode 100644 index 0000000000000000000000000000000000000000..d60f5d4a8c2674930e1f8bb2be21b8235967911f --- /dev/null +++ b/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>, +} + +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> { + 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::(|_this, _params| ControlFlow::Continue(())) + .notification::(|_this, _params| ControlFlow::Continue(())) + .notification::(|_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)) +} diff --git a/src/lsp/mod.rs b/src/lsp/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..b7f940d2984a5ac9197de84bc10356c527e001d9 --- /dev/null +++ b/src/lsp/mod.rs @@ -0,0 +1,3 @@ +pub mod client; + +pub use client::setup_lsp_client; diff --git a/src/main.rs b/src/main.rs index cb89df4d6ad4c445ad43ccd27e59144d67c7ba94..7acb550ddefeae7289d2d0e94d72a7f880f6dd75 100644 --- a/src/main.rs +++ b/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>, -} - -mod ControlFlowState { - pub struct Stop; -} - -#[derive(Clone)] -struct MCPServerConfig { - project_root: PathBuf, -} - -#[derive(Clone)] -struct MCPServer { - tool_router: ToolRouter, - 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, - 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, - ) -> Result { - 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, -} - -#[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> { 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::(|this, params| ControlFlow::Continue(())) - .notification::(|this, params| ControlFlow::Continue(())) - .notification::(|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()) diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..696238425ff55006929950dbcb289ee71ac6b09a --- /dev/null +++ b/src/mcp/mod.rs @@ -0,0 +1,4 @@ +pub mod server; +pub mod tools; + +pub use server::{MCPServer, MCPServerConfig}; diff --git a/src/mcp/server.rs b/src/mcp/server.rs new file mode 100644 index 0000000000000000000000000000000000000000..a10c0f2182a3c42f218fa7553b603b2f5ad072ea --- /dev/null +++ b/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, + 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, + ) -> Result { + 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()) + } + } +} diff --git a/src/mcp/tools/mod.rs b/src/mcp/tools/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..2cab8848e732b680b169afd95db0ceb84b1c1438 --- /dev/null +++ b/src/mcp/tools/mod.rs @@ -0,0 +1 @@ +pub mod read; diff --git a/src/mcp/tools/read.rs b/src/mcp/tools/read.rs new file mode 100644 index 0000000000000000000000000000000000000000..381bd152787d7ebb2602e7158af55b88589ecacf --- /dev/null +++ b/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, + pub diagnostics: DocumentDiagnosticReportResult, +} + +pub async fn call( + server: &MCPServer, + Parameters(args): Parameters, +) -> Result { + 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, + }) +}