use std::sync::Arc;
use crate::codec::{compact, toon};
use crate::daemon::DaemonClient;
use crate::daemon::protocol::SpawnParams;
use crate::interceptors::completions::compress_completions;
use crate::interceptors::symbols::compress_symbols;
use rmcp::{
ErrorData, ServerHandler,
model::{
CallToolRequestParams, CallToolResult, Content, ErrorCode, ListToolsResult,
PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool,
},
service::{RequestContext, RoleServer},
};
use tokio::sync::Mutex;
use tracing::{debug, info, warn};
use super::server::{
GetCompletionsInput, GetDiagnosticsInput, GetSymbolsInput, JsonSchema, detect_workspace_root,
merge_backend_args, resolve_language_backend,
};
use crate::daemon::resolve_workspace_root;
#[derive(Clone)]
pub struct DaemonMcpServer {
client: Arc<Mutex<Option<DaemonClient>>>,
root_cache: Arc<std::sync::Mutex<std::collections::HashMap<String, Option<String>>>>,
workspace_root: Arc<String>,
}
impl DaemonMcpServer {
pub fn new(workspace_root: String) -> Self {
let workspace_root = resolve_workspace_root(&workspace_root);
Self {
client: Arc::new(Mutex::new(None)),
root_cache: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
workspace_root: Arc::new(workspace_root),
}
}
async fn ensure_connected(&self) -> Result<(), ErrorData> {
let mut guard = self.client.lock().await;
if guard.is_some() {
return Ok(());
}
match DaemonClient::connect_or_start(&self.workspace_root).await {
Ok(client) => {
info!("Connected to lspz daemon for MCP");
*guard = Some(client);
Ok(())
}
Err(e) => {
warn!("Daemon connect failed, falling back to in-process: {e}");
Err(ErrorData::internal_error(
format!("Cannot connect to lspz daemon: {e}. Try running 'lspz daemon' first.",),
None,
))
}
}
}
async fn ensure_session(
&self,
language: &str,
backend: &str,
root_path: Option<String>,
extra_args: &[String],
) -> Result<String, ErrorData> {
self.ensure_connected().await?;
let mut guard = self.client.lock().await;
let client = guard
.as_mut()
.ok_or_else(|| ErrorData::internal_error("Daemon not connected", None))?;
let params = SpawnParams {
language: language.to_string(),
backend: backend.to_string(),
root_path: root_path.map(|s| s.to_string()),
extra_args: extra_args.to_vec(),
};
client.spawn_session(¶ms).await.map_err(|e| {
ErrorData::internal_error(format!("Failed to create LSP session: {e}"), None)
})
}
fn cached_workspace_root(&self, uri: &str) -> Option<String> {
if let Ok(cache) = self.root_cache.lock()
&& let Some(cached) = cache.get(uri)
{
return cached.clone();
}
let result = detect_workspace_root(uri);
if let Ok(mut cache) = self.root_cache.lock() {
cache.insert(uri.to_string(), result.clone());
}
result
}
async fn handle_diagnostics(&self, input: GetDiagnosticsInput) -> Result<String, ErrorData> {
let (language, backend) = resolve_language_backend(
&input.uri,
input.language.as_deref(),
input.backend.as_deref(),
)?;
let path = crate::uri::path_from_file_uri(&input.uri)
.map_err(|e| ErrorData::invalid_request(e, None))?;
let content = tokio::fs::read_to_string(&path)
.await
.map_err(|e| ErrorData::internal_error(format!("Cannot read file: {e}"), None))?;
let root = self.cached_workspace_root(&input.uri);
let extra = merge_backend_args(&input.uri, input.backend_args.as_deref());
let session_key = self
.ensure_session(&language, &backend, root, &extra)
.await?;
{
let mut guard = self.client.lock().await;
let client = guard.as_mut().unwrap();
client
.lsp_sync_document(&session_key, &input.uri, &language, &content)
.await
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
}
const DIAGNOSTIC_WAIT_MS: u64 = 10_000;
let params = {
let mut guard = self.client.lock().await;
let client = guard.as_mut().unwrap();
client
.lsp_wait_notify(
&session_key,
"textDocument/publishDiagnostics",
Some(&input.uri),
Some(DIAGNOSTIC_WAIT_MS),
)
.await
.unwrap_or(serde_json::Value::Null)
};
if params.is_null() {
return Ok(toon::diagnostics_to_toon(&compact::CompactDiagnostics {
version: 1,
uri: input.uri.clone(),
diagnostics: vec![],
}));
}
let compressed = compact::compress(¶ms)
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
let diags: compact::CompactDiagnostics = serde_json::from_value(compressed)
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
Ok(toon::diagnostics_to_toon(&diags))
}
async fn handle_completions(&self, input: GetCompletionsInput) -> Result<String, ErrorData> {
let (language, backend) = resolve_language_backend(
&input.uri,
input.language.as_deref(),
input.backend.as_deref(),
)?;
let path = crate::uri::path_from_file_uri(&input.uri)
.map_err(|e| ErrorData::invalid_request(e, None))?;
let content = tokio::fs::read_to_string(&path)
.await
.map_err(|e| ErrorData::internal_error(format!("Cannot read file: {e}"), None))?;
let root = self.cached_workspace_root(&input.uri);
let extra = merge_backend_args(&input.uri, input.backend_args.as_deref());
let session_key = self
.ensure_session(&language, &backend, root, &extra)
.await?;
{
let mut guard = self.client.lock().await;
let client = guard.as_mut().unwrap();
client
.lsp_sync_document(&session_key, &input.uri, &language, &content)
.await
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
let result = client
.lsp_request(
&session_key,
"textDocument/completion",
serde_json::json!({
"textDocument": { "uri": input.uri },
"position": { "line": input.line, "character": input.character },
}),
)
.await
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
let compressed = compress_completions(&result, 0, true)
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
toon::completions_to_toon(&compressed)
.map_err(|e| ErrorData::internal_error(e.to_string(), None))
}
}
async fn handle_symbols(&self, input: GetSymbolsInput) -> Result<String, ErrorData> {
let (language, backend) = resolve_language_backend(
&input.uri,
input.language.as_deref(),
input.backend.as_deref(),
)?;
let path = crate::uri::path_from_file_uri(&input.uri)
.map_err(|e| ErrorData::invalid_request(e, None))?;
let content = tokio::fs::read_to_string(&path)
.await
.map_err(|e| ErrorData::internal_error(format!("Cannot read file: {e}"), None))?;
let root = self.cached_workspace_root(&input.uri);
let extra = merge_backend_args(&input.uri, input.backend_args.as_deref());
let session_key = self
.ensure_session(&language, &backend, root, &extra)
.await?;
{
let mut guard = self.client.lock().await;
let client = guard.as_mut().unwrap();
client
.lsp_sync_document(&session_key, &input.uri, &language, &content)
.await
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
let result = client
.lsp_request(
&session_key,
"textDocument/documentSymbol",
serde_json::json!({
"textDocument": { "uri": input.uri },
}),
)
.await
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
let compressed = compress_symbols(&result)
.map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
toon::symbols_to_toon(&compressed)
.map_err(|e| ErrorData::internal_error(e.to_string(), None))
}
}
}
impl Default for DaemonMcpServer {
fn default() -> Self {
Self::new(
std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| ".".into()),
)
}
}
fn daemon_tool_definitions() -> Vec<Tool> {
vec![
Tool::new(
"get_diagnostics",
"Get LSP diagnostics for a file via lspz daemon. Returns TOON format. \
Language and backend are auto-detected from file extension if omitted.",
rmcp::model::object(GetDiagnosticsInput::json_schema()),
),
Tool::new(
"get_completions",
"Request LSP completions at a cursor position via lspz daemon. Returns TOON format. \
Language and backend are auto-detected from file extension if omitted.",
rmcp::model::object(GetCompletionsInput::json_schema()),
),
Tool::new(
"get_symbols",
"Retrieve document symbols via lspz daemon. Returns TOON format. \
Language and backend are auto-detected from file extension if omitted.",
rmcp::model::object(GetSymbolsInput::json_schema()),
),
]
}
impl ServerHandler for DaemonMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_instructions(
"lspz MCP server (daemon mode) — auto-connects to a running lspz daemon. \
If no daemon is running, one is started automatically.",
)
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, ErrorData> {
Ok(ListToolsResult::with_all_items(daemon_tool_definitions()))
}
async fn call_tool(
&self,
request: CallToolRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<CallToolResult, ErrorData> {
let CallToolRequestParams {
name, arguments, ..
} = request;
debug!(tool = %name, "Daemon MCP tool call");
match name.as_ref() {
"get_diagnostics" => {
let input: GetDiagnosticsInput = serde_json::from_value(serde_json::Value::Object(
arguments.unwrap_or_default(),
))
.map_err(|e| ErrorData::invalid_request(e.to_string(), None))?;
let result = self.handle_diagnostics(input).await?;
info!("get_diagnostics completed (daemon)");
Ok(CallToolResult::success(vec![Content::text(result)]))
}
"get_completions" => {
let input: GetCompletionsInput = serde_json::from_value(serde_json::Value::Object(
arguments.unwrap_or_default(),
))
.map_err(|e| ErrorData::invalid_request(e.to_string(), None))?;
let result = self.handle_completions(input).await?;
info!("get_completions completed (daemon)");
Ok(CallToolResult::success(vec![Content::text(result)]))
}
"get_symbols" => {
let input: GetSymbolsInput = serde_json::from_value(serde_json::Value::Object(
arguments.unwrap_or_default(),
))
.map_err(|e| ErrorData::invalid_request(e.to_string(), None))?;
let result = self.handle_symbols(input).await?;
info!("get_symbols completed (daemon)");
Ok(CallToolResult::success(vec![Content::text(result)]))
}
_ => Err(ErrorData::new(
ErrorCode::METHOD_NOT_FOUND,
format!("Unknown tool: {name}"),
None,
)),
}
}
}