use anyhow::{Result, anyhow};
use serde_json::Value;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use super::filesystem_manager::FilesystemManager;
use super::tool_server::ToolServer;
use super::toolbox::Toolbox;
use crate::lsp::LspClientManager;
use crate::mcp::{CallToolResult, Tool, ToolHandler};
const GREP_HOVER_THRESHOLD: usize = 10;
#[allow(dead_code, reason = "dead field available for future tool-level use")]
pub(super) struct ServerHealth {
pub(super) dead: Vec<String>,
pub(super) notification: Option<String>,
}
pub(super) async fn check_server_health(
client_manager: &LspClientManager,
touched_servers: &[String],
notified_offline: &std::sync::Mutex<HashSet<String>>,
) -> ServerHealth {
let mut alive = Vec::new();
let mut dead = Vec::new();
let clients = client_manager.clients().await;
for lang in touched_servers {
let ready = if let Some(c) = clients.get(lang) {
let client = c.lock().await;
matches!(
client.lifecycle(),
crate::lsp::state::ServerLifecycle::Healthy
)
} else {
false
};
if ready {
alive.push(lang.clone());
} else {
dead.push(lang.clone());
}
}
let mut notified = notified_offline
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut parts = Vec::new();
let recovered: Vec<String> = alive
.iter()
.filter(|lang| notified.remove(lang.as_str()))
.cloned()
.collect();
if !recovered.is_empty() {
let langs = recovered.join(", ");
parts.push(format!(
"Language server{} back online: {langs} \u{2014} \
diagnostics and language server enrichment re-enabled for \
{langs} files.",
if recovered.len() == 1 { "" } else { "s" },
));
}
let newly_dead: Vec<String> = dead
.iter()
.filter(|lang| notified.insert((*lang).clone()))
.cloned()
.collect();
if !newly_dead.is_empty() {
let langs = newly_dead.join(", ");
parts.push(format!(
"Language server{} unavailable: {langs} \u{2014} \
diagnostics unavailable for {langs} files. \
grep and glob still work but without \
language server enrichment.",
if newly_dead.len() == 1 { "" } else { "s" },
));
}
let notification = if parts.is_empty() {
None
} else {
Some(parts.join("\n\n"))
};
ServerHealth { dead, notification }
}
pub struct McpRouter {
toolbox: Arc<Toolbox>,
}
impl McpRouter {
#[must_use]
pub const fn new(toolbox: Arc<Toolbox>) -> Self {
Self { toolbox }
}
}
pub(super) fn expand_tilde(path: &str) -> String {
if (path == "~" || path.starts_with("~/"))
&& let Ok(home) = std::env::var("HOME")
{
return format!("{home}{}", &path[1..]);
}
path.to_string()
}
pub(super) fn resolve_path(file: &str) -> Result<PathBuf> {
let expanded = expand_tilde(file);
let path = PathBuf::from(&expanded);
if path.is_absolute() {
Ok(path)
} else {
let cwd = std::env::current_dir()
.map_err(|e| anyhow!("Failed to get current working directory: {e}"))?;
Ok(cwd.join(path))
}
}
pub(super) fn display_path(file: &str, fs: &FilesystemManager) -> String {
let path = Path::new(file);
fs.resolve_root(path).map_or_else(
|| file.to_string(),
|root| {
path.strip_prefix(&root).map_or_else(
|_| file.to_string(),
|rel| rel.to_string_lossy().to_string(),
)
},
)
}
impl ToolHandler for McpRouter {
fn list_tools(&self) -> Vec<Tool> {
vec![
Tool {
name: "grep".to_string(),
description: Some(format!("Search for a pattern across the workspace. Queries the full LSP symbol index and ripgrep in parallel. Use `|` for alternation (e.g., `foo|bar`). Scope with `glob` and `exclude` to narrow the file set. Returns per-symbol sections with definitions, hover docs, and references (\u{2264}{GREP_HOVER_THRESHOLD} symbols) or name+kind+location (>{GREP_HOVER_THRESHOLD}).")),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Regex pattern to search for (supports | for alternation)"
},
"glob": {
"type": "string",
"description": "Glob pattern to scope the search (e.g., src/**/*.rs)"
},
"exclude": {
"type": "string",
"description": "Glob pattern to exclude from matches"
},
"include_gitignored": {
"type": "boolean",
"description": "Include gitignored files (default: false)"
},
"include_hidden": {
"type": "boolean",
"description": "Include hidden files (default: false)"
}
},
"required": ["pattern"]
}),
annotations: Some(serde_json::json!({
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": false
})),
},
Tool {
name: "glob".to_string(),
description: Some("Browse the workspace. Auto-detects intent: file path → symbol outline, directory path → listing with symbols, glob pattern → matching files with symbols. Always shows outline-level symbols (structs, classes, enums, interfaces, modules, constants).".to_string()),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "A file path, directory path, or glob pattern (e.g., 'src/', 'src/main.rs', '**/*.rs')"
}
},
"required": ["pattern"]
}),
annotations: Some(serde_json::json!({
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": false
})),
},
Tool {
name: "start_editing".to_string(),
description: Some("Enter editing mode. Diagnostics are suppressed on all subsequent Edit/Write calls until done_editing is called. Call this before using Edit.".to_string()),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
}),
annotations: Some(serde_json::json!({
"readOnlyHint": false,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": false
})),
},
Tool {
name: "done_editing".to_string(),
description: Some("Exit editing mode and return LSP diagnostics for all modified files. Must be called after start_editing before using non-Edit tools.".to_string()),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
}),
annotations: Some(serde_json::json!({
"readOnlyHint": true,
"destructiveHint": false,
"idempotentHint": false,
"openWorldHint": false
})),
},
]
}
fn call_tool(
&self,
name: &str,
arguments: Option<serde_json::Value>,
parent_id: Option<i64>,
) -> Result<CallToolResult> {
if name == "start_editing" {
return Ok(CallToolResult::text(
"editing mode \u{2014} diagnostics deferred until done_editing",
));
}
if name == "done_editing" {
return Ok(CallToolResult::text("done editing"));
}
let params = arguments.unwrap_or(Value::Null);
let result = match name {
"grep" => self
.toolbox
.runtime
.block_on(self.toolbox.grep.execute(¶ms, parent_id)),
"glob" => self
.toolbox
.runtime
.block_on(self.toolbox.glob.execute(¶ms, parent_id)),
_ => return Err(anyhow!("Unknown tool: {name}")),
};
match result {
Ok(v) => {
let text = v.as_str().unwrap_or("").to_string();
Ok(CallToolResult::text(text))
}
Err(e) => Err(e),
}
}
}