From 9fe10cb0387848892730e479f93254095a25629f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 02:29:13 +0000 Subject: [PATCH 01/11] fix(mcp): default omitted path to workspace, not process cwd/$HOME MCP hosts often start oxcode with cwd=$HOME even when the agent is in a project folder. Omitting `path` previously resolved "." to that process cwd, so oxcode_watch could cold-index the entire home directory. Prefer OXCODE_ROOT / CLAUDE_PROJECT_DIR / WORKSPACE_FOLDER_PATHS, then MCP roots/list; refuse $HOME unless path was explicit. Co-authored-by: Michael Assaf --- README.md | 9 +- crates/oxcode-cli/src/mcp.rs | 479 +++++++++++++++++++++++++++++++---- prompts/arms/oxcode-mcp.md | 2 +- 3 files changed, 442 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index cefe645..aa77e77 100644 --- a/README.md +++ b/README.md @@ -60,9 +60,12 @@ Add the server to your agent. For Claude Code (`~/.claude.json`): ``` Once wired, have the agent call `oxcode_watch` once: it builds the index and -keeps it current as files change. Across multiple agents on one repo a file lock -elects a single writer (the one watcher/re-indexer) while the rest serve reads, so -you can run as many as you like. Then ask questions with `oxcode_explore`. +keeps it current as files change. When `path` is omitted, the project root comes +from `OXCODE_ROOT`, `CLAUDE_PROJECT_DIR`, `WORKSPACE_FOLDER_PATHS`, or the +client's MCP roots — not from the MCP process cwd (hosts often start servers in +`$HOME`). Across multiple agents on one repo a file lock elects a single writer +(the one watcher/re-indexer) while the rest serve reads, so you can run as many +as you like. Then ask questions with `oxcode_explore`. Optionally auto-allow the tools in `~/.claude/settings.json` (the query tools are read-only; `oxcode_watch` only builds/maintains the local index): diff --git a/crates/oxcode-cli/src/mcp.rs b/crates/oxcode-cli/src/mcp.rs index b4dd607..9b3b0b5 100644 --- a/crates/oxcode-cli/src/mcp.rs +++ b/crates/oxcode-cli/src/mcp.rs @@ -23,10 +23,12 @@ use notify_debouncer_full::{ }; use oxcode_core::{GraphDirection, IndexStats, NodeKind, ProjectIndex}; use rmcp::{ - ErrorData as McpError, ServerHandler, ServiceExt, + ErrorData as McpError, Peer, RoleServer, ServerHandler, ServiceExt, handler::server::{router::tool::ToolRouter, wrapper::Parameters}, model::{CallToolResult, Content, ServerCapabilities, ServerInfo, TasksCapability}, - schemars, task_handler, + schemars, + service::NotificationContext, + task_handler, task_manager::OperationProcessor, tool, tool_handler, tool_router, transport::stdio, @@ -70,17 +72,19 @@ pub(crate) fn serve() -> anyhow::Result<()> { } /// Server instructions steering agents to `oxcode_watch` then `oxcode_explore`. -const INSTRUCTIONS: &str = "This server answers questions about the code repository in the working \ -directory. First call `oxcode_watch` (optional `path`, defaults to the working directory): it builds \ -the index if needed and keeps it current as files change. Only one MCP instance watches a given \ -folder at a time — a file lock elects a single writer; other instances serve reads and take over \ -automatically if the writer exits. Then, for almost any code-understanding question, call \ -`oxcode_explore` first with the user's question verbatim: it returns the most relevant symbols \ -(ranked by graph centrality), their source, the relationships among them, the n-ary hyperedges they \ -belong to (trait impl groups and container/module membership, ranked by hypergraph PageRank — the \ -architecture-altitude layer), the blast radius, and the call flow — in one call. Use \ -`oxcode_callers`/`oxcode_callees`/`oxcode_symbol` to follow specific edges, and \ -`oxcode_search`/`oxcode_files` only when explore did not surface the target. Prefer these query \ +const INSTRUCTIONS: &str = "This server answers questions about the code repository in the current \ +project. First call `oxcode_watch` (optional `path`): it builds the index if needed and keeps it \ +current as files change. When `path` is omitted, the project root is taken from OXCODE_ROOT, \ +CLAUDE_PROJECT_DIR, WORKSPACE_FOLDER_PATHS, or the client's MCP roots — not from this process's \ +cwd, which MCP hosts often set to $HOME. Pass `path` explicitly when in doubt. Only one MCP \ +instance watches a given folder at a time — a file lock elects a single writer; other instances \ +serve reads and take over automatically if the writer exits. Then, for almost any \ +code-understanding question, call `oxcode_explore` first with the user's question verbatim: it \ +returns the most relevant symbols (ranked by graph centrality), their source, the relationships \ +among them, the n-ary hyperedges they belong to (trait impl groups and container/module membership, \ +ranked by hypergraph PageRank — the architecture-altitude layer), the blast radius, and the call \ +flow — in one call. Use `oxcode_callers`/`oxcode_callees`/`oxcode_symbol` to follow specific edges, \ +and `oxcode_search`/`oxcode_files` only when explore did not surface the target. Prefer these query \ tools over shelling out to grep or reading files. Every tool except `oxcode_watch` is read-only; do \ not edit source files."; @@ -103,6 +107,9 @@ pub(crate) struct OxcodeServer { writers: Arc>>>, /// Roots this process is a standby for (lost the lock; a failover task polls). standbys: Arc>>, + /// Client MCP roots (`roots/list`), cached so omitted `path` defaults to the + /// workspace rather than this process's cwd (often `$HOME` under MCP hosts). + client_roots: Arc>>>, /// File-watcher debounce window. debounce: Duration, /// Failover poll interval for standbys. @@ -128,7 +135,8 @@ struct WriterState { pub(crate) struct ExploreParams { /// The task or question about the codebase, in natural language. pub query: String, - /// Project root; defaults to the server's working directory. + /// Project root; defaults to the workspace (OXCODE_ROOT / CLAUDE_PROJECT_DIR / + /// WORKSPACE_FOLDER_PATHS / MCP roots), never silently to `$HOME`. pub path: Option, /// Maximum source characters to render (default 20000). pub max_bytes: Option, @@ -139,7 +147,8 @@ pub(crate) struct ExploreParams { pub(crate) struct SearchParams { /// Keywords matched against symbol names, signatures, and docs. pub query: String, - /// Project root; defaults to the server's working directory. + /// Project root; defaults to the workspace (OXCODE_ROOT / CLAUDE_PROJECT_DIR / + /// WORKSPACE_FOLDER_PATHS / MCP roots), never silently to `$HOME`. pub path: Option, /// Maximum number of matches (default 30). pub limit: Option, @@ -152,7 +161,8 @@ pub(crate) struct SearchParams { pub(crate) struct CallParams { /// Selector: a qualified name, `name:`, `element:`, or `file::`. pub selector: String, - /// Project root; defaults to the server's working directory. + /// Project root; defaults to the workspace (OXCODE_ROOT / CLAUDE_PROJECT_DIR / + /// WORKSPACE_FOLDER_PATHS / MCP roots), never silently to `$HOME`. pub path: Option, /// Maximum hop depth (default 2). pub depth: Option, @@ -165,7 +175,8 @@ pub(crate) struct CallParams { pub(crate) struct SymbolParams { /// Selector: a qualified name, `name:`, `element:`, or `file::`. pub selector: String, - /// Project root; defaults to the server's working directory. + /// Project root; defaults to the workspace (OXCODE_ROOT / CLAUDE_PROJECT_DIR / + /// WORKSPACE_FOLDER_PATHS / MCP roots), never silently to `$HOME`. pub path: Option, } @@ -174,7 +185,8 @@ pub(crate) struct SymbolParams { pub(crate) struct FilesParams { /// Keywords matched against file paths and their symbols. pub query: String, - /// Project root; defaults to the server's working directory. + /// Project root; defaults to the workspace (OXCODE_ROOT / CLAUDE_PROJECT_DIR / + /// WORKSPACE_FOLDER_PATHS / MCP roots), never silently to `$HOME`. pub path: Option, /// Maximum number of files (default 30). pub limit: Option, @@ -183,14 +195,17 @@ pub(crate) struct FilesParams { /// A project-root pointer. #[derive(Debug, Deserialize, schemars::JsonSchema)] pub(crate) struct StatusParams { - /// Project root; defaults to the server's working directory. + /// Project root; defaults to the workspace (OXCODE_ROOT / CLAUDE_PROJECT_DIR / + /// WORKSPACE_FOLDER_PATHS / MCP roots), never silently to `$HOME`. pub path: Option, } /// A project root to watch and keep indexed. #[derive(Debug, Deserialize, schemars::JsonSchema)] pub(crate) struct WatchParams { - /// Project root to watch; defaults to the server's working directory. + /// Project root to watch; defaults to the workspace (OXCODE_ROOT / + /// CLAUDE_PROJECT_DIR / WORKSPACE_FOLDER_PATHS / MCP roots), never silently to + /// `$HOME`. pub path: Option, } @@ -216,20 +231,22 @@ impl OxcodeServer { operations: Arc::new(Mutex::new(OperationProcessor::new())), writers: Arc::new(std::sync::Mutex::new(HashMap::new())), standbys: Arc::new(std::sync::Mutex::new(HashSet::new())), + client_roots: Arc::new(std::sync::Mutex::new(None)), debounce, poll, } } #[tool( - description = "Start (or join) watching a project so its index is built and kept current as files change. Exactly one MCP instance per folder becomes the writer (it holds a file lock and re-indexes on changes); other instances become readers that just serve queries and automatically take over if the writer exits. Call this once before querying. Optional `path` defaults to the working directory.", + description = "Start (or join) watching a project so its index is built and kept current as files change. Exactly one MCP instance per folder becomes the writer (it holds a file lock and re-indexes on changes); other instances become readers that just serve queries and automatically take over if the writer exits. Call this once before querying. Optional `path` defaults to the workspace (OXCODE_ROOT / CLAUDE_PROJECT_DIR / WORKSPACE_FOLDER_PATHS / MCP roots); never silently to $HOME.", execution(task_support = "optional") )] async fn oxcode_watch( &self, Parameters(params): Parameters, + peer: Peer, ) -> Result { - let root = resolve_root(params.path); + let root = self.resolve_root(params.path, &peer).await?; // Idempotent: already participating for this root. if self.is_writer(&root) { @@ -286,8 +303,9 @@ impl OxcodeServer { async fn oxcode_explore( &self, Parameters(params): Parameters, + peer: Peer, ) -> Result { - let index = self.index_for(params.path).await?; + let index = self.index_for(params.path, &peer).await?; let query = params.query; let max_bytes = params.max_bytes.unwrap_or(20_000); let report = blocking(move || index.context(&query, 8, 1, max_bytes)).await?; @@ -300,8 +318,9 @@ impl OxcodeServer { async fn oxcode_search( &self, Parameters(params): Parameters, + peer: Peer, ) -> Result { - let index = self.index_for(params.path).await?; + let index = self.index_for(params.path, &peer).await?; let query = params.query; let limit = params.limit.unwrap_or(30); let kinds = parse_kinds(params.kinds.as_deref()); @@ -313,16 +332,18 @@ impl OxcodeServer { async fn oxcode_callers( &self, Parameters(params): Parameters, + peer: Peer, ) -> Result { - self.call_graph(params, GraphDirection::Incoming).await + self.call_graph(params, GraphDirection::Incoming, &peer).await } #[tool(description = "Find the functions called by the given symbol (outgoing call graph).")] async fn oxcode_callees( &self, Parameters(params): Parameters, + peer: Peer, ) -> Result { - self.call_graph(params, GraphDirection::Outgoing).await + self.call_graph(params, GraphDirection::Outgoing, &peer).await } #[tool( @@ -331,8 +352,9 @@ impl OxcodeServer { async fn oxcode_symbol( &self, Parameters(params): Parameters, + peer: Peer, ) -> Result { - let index = self.index_for(params.path).await?; + let index = self.index_for(params.path, &peer).await?; let selector = params.selector; let value = blocking(move || resolve_symbol(&index, &selector)).await?; json_result(&value) @@ -342,8 +364,9 @@ impl OxcodeServer { async fn oxcode_files( &self, Parameters(params): Parameters, + peer: Peer, ) -> Result { - let index = self.index_for(params.path).await?; + let index = self.index_for(params.path, &peer).await?; let query = params.query; let limit = params.limit.unwrap_or(30); let report = blocking(move || index.search_files(&query, limit)).await?; @@ -356,8 +379,9 @@ impl OxcodeServer { async fn oxcode_status( &self, Parameters(params): Parameters, + peer: Peer, ) -> Result { - let root = resolve_root(params.path); + let root = self.resolve_root(params.path, &peer).await?; let (role, watching, reindexes) = self.watch_state(&root); let status_root = root.clone(); let database = blocking(move || oxcode_core::project_status(&status_root)).await?; @@ -373,8 +397,9 @@ impl OxcodeServer { &self, params: CallParams, direction: GraphDirection, + peer: &Peer, ) -> Result { - let index = self.index_for(params.path).await?; + let index = self.index_for(params.path, peer).await?; let selector = params.selector; let depth = params.depth.unwrap_or(2); let limit = params.limit.unwrap_or(50); @@ -382,12 +407,16 @@ impl OxcodeServer { json_result(&report) } - /// Opens the index for `path` (default cwd). If this process is the writer for - /// the root, the opened reader is cached and evicted on each reindex; any other - /// process opens fresh per query so it reflects the writer's latest commit. A - /// missing index is not built here — call `oxcode_watch` first. - async fn index_for(&self, path: Option) -> Result, McpError> { - let root = resolve_root(path); + /// Opens the index for `path` (default: workspace root). If this process is the + /// writer for the root, the opened reader is cached and evicted on each reindex; + /// any other process opens fresh per query so it reflects the writer's latest + /// commit. A missing index is not built here — call `oxcode_watch` first. + async fn index_for( + &self, + path: Option, + peer: &Peer, + ) -> Result, McpError> { + let root = self.resolve_root(path, peer).await?; if self.is_writer(&root) { if let Some(index) = self.indexes.lock().await.get(&root) { return Ok(Arc::clone(index)); @@ -562,6 +591,83 @@ impl OxcodeServer { } ("reader", false, 0) } + + /// Resolves the project root from an optional `path`, preferring workspace + /// signals over this process's cwd. MCP hosts often start servers with + /// `cwd=$HOME` even when the agent is in a project folder; omitting `path` + /// must not silently index the home directory. + async fn resolve_root( + &self, + path: Option, + peer: &Peer, + ) -> Result { + let explicit = path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(PathBuf::from); + let raw = if let Some(explicit_path) = explicit.clone() { + explicit_path + } else if let Some(from_env) = env_project_root() { + from_env + } else if let Some(from_roots) = self.workspace_root_from_client(peer).await { + from_roots + } else { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) + }; + let root = canonicalize_root(raw); + if explicit.is_none() && is_home_directory(&root) { + return Err(McpError::invalid_params( + format!( + "refusing to use home directory {} as the project root — MCP hosts often \ + start this server with cwd=$HOME even when your workspace is elsewhere. \ + Pass `path` (the project folder), or set OXCODE_ROOT / CLAUDE_PROJECT_DIR \ + / WORKSPACE_FOLDER_PATHS.", + root.display() + ), + None, + )); + } + Ok(root) + } + + /// First client MCP root, refreshing the cache via `roots/list` when needed. + async fn workspace_root_from_client(&self, peer: &Peer) -> Option { + { + let cache = self + .client_roots + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(roots) = cache.as_ref() { + return roots.first().cloned(); + } + } + peer.peer_info() + .and_then(|info| info.capabilities.roots.as_ref())?; + let roots = match peer.list_roots().await { + Ok(result) => result + .roots + .iter() + .filter_map(|root| file_uri_to_path(&root.uri)) + .collect::>(), + Err(_) => return None, + }; + let first = roots.first().cloned(); + *self + .client_roots + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(roots); + first + } + + /// Refreshes the cached client MCP roots after `notifications/roots/list_changed`. + async fn refresh_client_roots(&self, peer: &Peer) { + *self + .client_roots + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + let _ = self.workspace_root_from_client(peer).await; + } } /// Re-indexes `root` on each debounced change tick until the watcher stops. @@ -671,15 +777,113 @@ impl ServerHandler for OxcodeServer { ) .with_instructions(INSTRUCTIONS) } + + async fn on_roots_list_changed(&self, context: NotificationContext) { + self.refresh_client_roots(&context.peer).await; + } +} + +/// Project root from host-injected environment variables, in priority order. +/// +/// MCP hosts frequently leave the server process cwd at `$HOME` while advertising +/// the real workspace via these variables (Claude Code → `CLAUDE_PROJECT_DIR`, +/// Cursor → `WORKSPACE_FOLDER_PATHS`). `OXCODE_ROOT` is the explicit override. +fn env_project_root() -> Option { + for key in ["OXCODE_ROOT", "CLAUDE_PROJECT_DIR"] { + if let Ok(value) = std::env::var(key) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Some(PathBuf::from(trimmed)); + } + } + } + std::env::var("WORKSPACE_FOLDER_PATHS") + .ok() + .and_then(|value| first_workspace_folder(&value)) +} + +/// First folder from a `WORKSPACE_FOLDER_PATHS` value (single path or CSV). +fn first_workspace_folder(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + let as_path = PathBuf::from(trimmed); + if as_path.is_dir() { + return Some(as_path); + } + // Multi-root workspaces: comma-separated. Do not split on `:` — that breaks + // Windows drive letters (`C:\...`). + trimmed + .split(',') + .map(str::trim) + .find(|part| !part.is_empty()) + .map(PathBuf::from) } -/// Resolves the project root from an optional path argument, canonicalizing -/// best-effort so the reader cache, the writer registry, and the lock file all key -/// on the same absolute path (FS events report canonical paths). Falls back to the +/// Whether `path` is the current user's home directory (best-effort). +fn is_home_directory(path: &Path) -> bool { + let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) else { + return false; + }; + let home = PathBuf::from(home); + canonicalize_root(path.to_path_buf()) == canonicalize_root(home) +} + +/// Canonicalizes best-effort so reader cache / writer registry / lock file key on +/// the same absolute path (FS events report canonical paths). Falls back to the /// raw path when it does not exist yet. -fn resolve_root(path: Option) -> PathBuf { - let raw = PathBuf::from(path.unwrap_or_else(|| ".".to_owned())); - std::fs::canonicalize(&raw).unwrap_or(raw) +fn canonicalize_root(path: PathBuf) -> PathBuf { + std::fs::canonicalize(&path).unwrap_or(path) +} + +/// Converts a `file://` MCP root URI into a filesystem path. +fn file_uri_to_path(uri: &str) -> Option { + let rest = uri.strip_prefix("file://")?; + let path = if let Some(path) = rest.strip_prefix("localhost") { + path + } else if rest.starts_with('/') { + rest + } else { + return None; + }; + let decoded = percent_decode(path); + if decoded.is_empty() { + return None; + } + Some(PathBuf::from(decoded)) +} + +/// Decodes `%XX` sequences in a URI path; returns the input unchanged when none. +fn percent_decode(input: &str) -> String { + if !input.contains('%') { + return input.to_owned(); + } + let bytes = input.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' + && index + 2 < bytes.len() + && let (Some(high), Some(low)) = (hex_nibble(bytes[index + 1]), hex_nibble(bytes[index + 2])) + { + out.push((high << 4) | low); + index += 3; + continue; + } + out.push(bytes[index]); + index += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +fn hex_nibble(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } } /// Parses caller-supplied kind strings into `NodeKind`, dropping unknown ones. @@ -732,23 +936,110 @@ mod tests { use std::time::Duration; + use std::sync::{Mutex, MutexGuard}; + use rmcp::{ ClientHandler, RoleClient, model::{ - CallToolRequestParams, ClientRequest, GetTaskInfoParams, GetTaskResultParams, Request, + CallToolRequestParams, ClientCapabilities, ClientInfo, ClientRequest, + GetTaskInfoParams, GetTaskResultParams, Implementation, ListRootsResult, Request, Root, ServerResult, TaskStatus, TaskSupport, }, - service::RunningService, + service::{RequestContext, RunningService}, }; use super::*; + /// Serializes tests that mutate process-global project-root env vars. + static PROJECT_ROOT_ENV_LOCK: Mutex<()> = Mutex::new(()); + + /// Env keys consulted by [`env_project_root`], for save/restore around tests. + const PROJECT_ROOT_ENV_KEYS: &[&str] = + &["OXCODE_ROOT", "CLAUDE_PROJECT_DIR", "WORKSPACE_FOLDER_PATHS"]; + + /// Clears project-root env vars for the duration of a test; restores on drop. + struct ProjectRootEnvGuard { + _lock: MutexGuard<'static, ()>, + previous: Vec<(&'static str, Option)>, + } + + impl ProjectRootEnvGuard { + fn clear() -> Self { + let lock = PROJECT_ROOT_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let previous = PROJECT_ROOT_ENV_KEYS + .iter() + .map(|&key| (key, std::env::var_os(key))) + .collect::>(); + // SAFETY: held exclusively via PROJECT_ROOT_ENV_LOCK for this process. + unsafe { + for key in PROJECT_ROOT_ENV_KEYS { + std::env::remove_var(key); + } + } + Self { + _lock: lock, + previous, + } + } + + fn set(&self, key: &str, value: impl AsRef) { + // SAFETY: guard holds PROJECT_ROOT_ENV_LOCK. + unsafe { + std::env::set_var(key, value); + } + } + } + + impl Drop for ProjectRootEnvGuard { + fn drop(&mut self) { + // SAFETY: guard still holds PROJECT_ROOT_ENV_LOCK until drop completes. + unsafe { + for (key, value) in self.previous.drain(..) { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + } + } + /// Minimal MCP client; the server is what these tests exercise. #[derive(Clone, Default)] struct TestClient; impl ClientHandler for TestClient {} + /// MCP client that advertises workspace roots (Cursor / Claude Code do this). + #[derive(Clone)] + struct RootsClient { + roots: Vec, + } + + impl ClientHandler for RootsClient { + fn get_info(&self) -> ClientInfo { + ClientInfo::new( + ClientCapabilities::builder().enable_roots().build(), + Implementation::from_build_env(), + ) + } + + fn list_roots( + &self, + _context: RequestContext, + ) -> impl std::future::Future> + Send + '_ + { + let roots = self + .roots + .iter() + .map(|path| Root::new(format!("file://{}", path.display()))) + .collect(); + std::future::ready(Ok(ListRootsResult::new(roots))) + } + } + /// Wires a fresh `OxcodeServer` (with the given intervals) to a `TestClient` /// over an in-memory duplex pipe and returns the connected client service. async fn connect(debounce: Duration, poll: Duration) -> RunningService { @@ -868,6 +1159,106 @@ mod tests { status } + #[test] + fn env_project_root_prefers_oxcode_root() { + let project = tempfile::TempDir::new().expect("temp"); + let guard = ProjectRootEnvGuard::clear(); + guard.set("OXCODE_ROOT", project.path()); + let resolved = env_project_root().expect("OXCODE_ROOT"); + assert_eq!( + canonicalize_root(resolved), + canonicalize_root(project.path().to_path_buf()) + ); + } + + #[test] + fn first_workspace_folder_accepts_csv_and_single_path() { + let project = tempfile::TempDir::new().expect("temp"); + let path = project.path().to_string_lossy().into_owned(); + assert_eq!( + first_workspace_folder(&path), + Some(PathBuf::from(&path)), + "existing single path wins without splitting" + ); + assert_eq!( + first_workspace_folder(&format!("{path},/does/not/exist")), + Some(PathBuf::from(&path)) + ); + assert_eq!( + first_workspace_folder("/missing/a,/missing/b"), + Some(PathBuf::from("/missing/a")) + ); + } + + #[test] + fn file_uri_to_path_decodes_file_roots() { + assert_eq!( + file_uri_to_path("file:///Users/snowmead/opt/jinttai"), + Some(PathBuf::from("/Users/snowmead/opt/jinttai")) + ); + assert_eq!( + file_uri_to_path("file://localhost/tmp/project%20name"), + Some(PathBuf::from("/tmp/project name")) + ); + assert_eq!(file_uri_to_path("https://example.com"), None); + } + + #[test] + fn is_home_directory_matches_home_env() { + let home = tempfile::TempDir::new().expect("home"); + // Reuse the project-root env lock so HOME mutations never race other tests. + let _guard = ProjectRootEnvGuard::clear(); + let previous_home = std::env::var_os("HOME"); + // SAFETY: PROJECT_ROOT_ENV_LOCK is held via `_guard`. + unsafe { + std::env::set_var("HOME", home.path()); + } + assert!(is_home_directory(home.path())); + assert!(!is_home_directory(&home.path().join("opt/jinttai"))); + // SAFETY: restore HOME before `_guard` drops and releases the lock. + unsafe { + match previous_home { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + } + } + + #[tokio::test] + async fn omitted_path_uses_client_mcp_roots_not_process_cwd() { + let _env = ProjectRootEnvGuard::clear(); + let project = rust_project(); + let (server_transport, client_transport) = tokio::io::duplex(4096); + tokio::spawn(async move { + let server = + OxcodeServer::new_with(Duration::from_millis(50), Duration::from_millis(150)) + .serve(server_transport) + .await + .expect("server serve"); + let _ = server.waiting().await; + }); + let client = RootsClient { + roots: vec![project.path().to_path_buf()], + } + .serve(client_transport) + .await + .expect("client connect"); + + // No `path` argument: must resolve via roots/list to the project, not cwd. + let result = client + .call_tool(tool_call("oxcode_watch", serde_json::json!({}))) + .await + .expect("watch without path"); + let body: serde_json::Value = + serde_json::from_str(result_text(&result)).expect("watch json"); + assert_eq!(body["role"], "writer"); + assert_eq!( + canonicalize_root(PathBuf::from(body["root"].as_str().expect("root"))), + canonicalize_root(project.path().to_path_buf()), + "omitted path must use the client's MCP root, not the server process cwd" + ); + } + /// `flock` is per open-file-description on macOS/Linux: a second independent /// open of the same path cannot take the lock the first holds. This pins the /// platform behavior the writer election depends on. diff --git a/prompts/arms/oxcode-mcp.md b/prompts/arms/oxcode-mcp.md index 31cbbf7..7b74c09 100644 --- a/prompts/arms/oxcode-mcp.md +++ b/prompts/arms/oxcode-mcp.md @@ -12,6 +12,6 @@ Available tools: - `oxcode_files { query, path?, limit? }` — keyword search over indexed files. - `oxcode_status { path? }` — index status (element/relation counts). -Selectors may be qualified names, `name:`, `element:`, or `file::`. The `path` argument defaults to the indexed repository, so you can omit it. +Selectors may be qualified names, `name:`, `element:`, or `file::`. The `path` argument defaults to the workspace project root (`OXCODE_ROOT` / `CLAUDE_PROJECT_DIR` / `WORKSPACE_FOLDER_PATHS` / MCP roots), so you can omit it — pass it explicitly if the host did not advertise a workspace. Tool results are JSON with definition paths, line ranges, signatures, docstrings, source previews, and relationship call sites. Use those fields as evidence; do not open files just to recover line numbers or a short definition already present in the tool output. After `oxcode_explore`, use at most two targeted follow-up tool calls unless you are stuck. From 8df232deeef05ff9a2a8be8a88f0de3f74e4a986 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 03:26:26 +0000 Subject: [PATCH 02/11] style(mcp): rustfmt for CI fmt-check Co-authored-by: Michael Assaf --- crates/oxcode-cli/src/mcp.rs | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/oxcode-cli/src/mcp.rs b/crates/oxcode-cli/src/mcp.rs index 9b3b0b5..894a6f3 100644 --- a/crates/oxcode-cli/src/mcp.rs +++ b/crates/oxcode-cli/src/mcp.rs @@ -334,7 +334,8 @@ impl OxcodeServer { Parameters(params): Parameters, peer: Peer, ) -> Result { - self.call_graph(params, GraphDirection::Incoming, &peer).await + self.call_graph(params, GraphDirection::Incoming, &peer) + .await } #[tool(description = "Find the functions called by the given symbol (outgoing call graph).")] @@ -343,7 +344,8 @@ impl OxcodeServer { Parameters(params): Parameters, peer: Peer, ) -> Result { - self.call_graph(params, GraphDirection::Outgoing, &peer).await + self.call_graph(params, GraphDirection::Outgoing, &peer) + .await } #[tool( @@ -865,7 +867,8 @@ fn percent_decode(input: &str) -> String { while index < bytes.len() { if bytes[index] == b'%' && index + 2 < bytes.len() - && let (Some(high), Some(low)) = (hex_nibble(bytes[index + 1]), hex_nibble(bytes[index + 2])) + && let (Some(high), Some(low)) = + (hex_nibble(bytes[index + 1]), hex_nibble(bytes[index + 2])) { out.push((high << 4) | low); index += 3; @@ -934,9 +937,10 @@ mod tests { //! and the task lifecycle. The cross-process guarantee is proven separately by //! `tests/multiprocess.rs` (real spawned processes). - use std::time::Duration; - - use std::sync::{Mutex, MutexGuard}; + use std::{ + sync::{Mutex, MutexGuard}, + time::Duration, + }; use rmcp::{ ClientHandler, RoleClient, @@ -954,8 +958,11 @@ mod tests { static PROJECT_ROOT_ENV_LOCK: Mutex<()> = Mutex::new(()); /// Env keys consulted by [`env_project_root`], for save/restore around tests. - const PROJECT_ROOT_ENV_KEYS: &[&str] = - &["OXCODE_ROOT", "CLAUDE_PROJECT_DIR", "WORKSPACE_FOLDER_PATHS"]; + const PROJECT_ROOT_ENV_KEYS: &[&str] = &[ + "OXCODE_ROOT", + "CLAUDE_PROJECT_DIR", + "WORKSPACE_FOLDER_PATHS", + ]; /// Clears project-root env vars for the duration of a test; restores on drop. struct ProjectRootEnvGuard { From 7cd93f2300a886887b80c60339ead2feb7343ad7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 03:37:18 +0000 Subject: [PATCH 03/11] chore(deps): bump anyhow and crossbeam-epoch for cargo-deny RUSTSEC-2026-0190 / RUSTSEC-2026-0204 fail the deny gate on a stale lockfile. Co-authored-by: Michael Assaf --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eda7f82..f2360a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -86,9 +86,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arrow-array" @@ -502,9 +502,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] From 8d31a6020a3c1fc52bac0ad9edc5ab1868b399e5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 03:49:57 +0000 Subject: [PATCH 04/11] fix(mcp): avoid nested roots/list in tool handlers Fetch MCP roots only from on_initialized / roots/list_changed. Tool handlers wait briefly for that in-flight fetch and never issue nested roots/list (which can hang some hosts). Empty successful lists leave the cache as None so a later list_changed can still populate it. Co-authored-by: Michael Assaf --- crates/oxcode-cli/src/mcp.rs | 147 +++++++++++++++++++---------------- 1 file changed, 81 insertions(+), 66 deletions(-) diff --git a/crates/oxcode-cli/src/mcp.rs b/crates/oxcode-cli/src/mcp.rs index 894a6f3..94af366 100644 --- a/crates/oxcode-cli/src/mcp.rs +++ b/crates/oxcode-cli/src/mcp.rs @@ -49,6 +49,11 @@ const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(400); /// `OXCODE_WATCH_POLL_MS`. const DEFAULT_POLL: Duration = Duration::from_secs(3); +/// How long an omitted-`path` resolve will wait for the in-flight +/// `on_initialized` roots fetch before falling through to cwd. Never starts a +/// nested `roots/list` from a tool handler. +const ROOTS_READY_WAIT: Duration = Duration::from_millis(500); + /// Filename of the advisory single-writer lock, inside the `.oxcode` index dir. const WATCH_LOCK_FILE: &str = "watch.lock"; @@ -110,6 +115,12 @@ pub(crate) struct OxcodeServer { /// Client MCP roots (`roots/list`), cached so omitted `path` defaults to the /// workspace rather than this process's cwd (often `$HOME` under MCP hosts). client_roots: Arc>>>, + /// Becomes `true` after the first init-time roots fetch attempt finishes (or + /// is skipped because the client has no roots capability). Tools wait on this + /// instead of issuing nested `roots/list` calls. + roots_ready: tokio::sync::watch::Receiver, + /// Sender half for [`Self::roots_ready`]. + roots_ready_tx: Arc>, /// File-watcher debounce window. debounce: Duration, /// Failover poll interval for standbys. @@ -225,6 +236,7 @@ impl OxcodeServer { /// tiny values). #[must_use] fn new_with(debounce: Duration, poll: Duration) -> Self { + let (roots_ready_tx, roots_ready) = tokio::sync::watch::channel(false); Self { tool_router: Self::tool_router(), indexes: Arc::new(Mutex::new(HashMap::new())), @@ -232,6 +244,8 @@ impl OxcodeServer { writers: Arc::new(std::sync::Mutex::new(HashMap::new())), standbys: Arc::new(std::sync::Mutex::new(HashSet::new())), client_roots: Arc::new(std::sync::Mutex::new(None)), + roots_ready, + roots_ready_tx: Arc::new(roots_ready_tx), debounce, poll, } @@ -244,9 +258,8 @@ impl OxcodeServer { async fn oxcode_watch( &self, Parameters(params): Parameters, - peer: Peer, ) -> Result { - let root = self.resolve_root(params.path, &peer).await?; + let root = self.resolve_root(params.path).await?; // Idempotent: already participating for this root. if self.is_writer(&root) { @@ -303,9 +316,8 @@ impl OxcodeServer { async fn oxcode_explore( &self, Parameters(params): Parameters, - peer: Peer, ) -> Result { - let index = self.index_for(params.path, &peer).await?; + let index = self.index_for(params.path).await?; let query = params.query; let max_bytes = params.max_bytes.unwrap_or(20_000); let report = blocking(move || index.context(&query, 8, 1, max_bytes)).await?; @@ -318,9 +330,8 @@ impl OxcodeServer { async fn oxcode_search( &self, Parameters(params): Parameters, - peer: Peer, ) -> Result { - let index = self.index_for(params.path, &peer).await?; + let index = self.index_for(params.path).await?; let query = params.query; let limit = params.limit.unwrap_or(30); let kinds = parse_kinds(params.kinds.as_deref()); @@ -332,20 +343,16 @@ impl OxcodeServer { async fn oxcode_callers( &self, Parameters(params): Parameters, - peer: Peer, ) -> Result { - self.call_graph(params, GraphDirection::Incoming, &peer) - .await + self.call_graph(params, GraphDirection::Incoming).await } #[tool(description = "Find the functions called by the given symbol (outgoing call graph).")] async fn oxcode_callees( &self, Parameters(params): Parameters, - peer: Peer, ) -> Result { - self.call_graph(params, GraphDirection::Outgoing, &peer) - .await + self.call_graph(params, GraphDirection::Outgoing).await } #[tool( @@ -354,9 +361,8 @@ impl OxcodeServer { async fn oxcode_symbol( &self, Parameters(params): Parameters, - peer: Peer, ) -> Result { - let index = self.index_for(params.path, &peer).await?; + let index = self.index_for(params.path).await?; let selector = params.selector; let value = blocking(move || resolve_symbol(&index, &selector)).await?; json_result(&value) @@ -366,9 +372,8 @@ impl OxcodeServer { async fn oxcode_files( &self, Parameters(params): Parameters, - peer: Peer, ) -> Result { - let index = self.index_for(params.path, &peer).await?; + let index = self.index_for(params.path).await?; let query = params.query; let limit = params.limit.unwrap_or(30); let report = blocking(move || index.search_files(&query, limit)).await?; @@ -381,9 +386,8 @@ impl OxcodeServer { async fn oxcode_status( &self, Parameters(params): Parameters, - peer: Peer, ) -> Result { - let root = self.resolve_root(params.path, &peer).await?; + let root = self.resolve_root(params.path).await?; let (role, watching, reindexes) = self.watch_state(&root); let status_root = root.clone(); let database = blocking(move || oxcode_core::project_status(&status_root)).await?; @@ -399,9 +403,8 @@ impl OxcodeServer { &self, params: CallParams, direction: GraphDirection, - peer: &Peer, ) -> Result { - let index = self.index_for(params.path, peer).await?; + let index = self.index_for(params.path).await?; let selector = params.selector; let depth = params.depth.unwrap_or(2); let limit = params.limit.unwrap_or(50); @@ -413,12 +416,8 @@ impl OxcodeServer { /// writer for the root, the opened reader is cached and evicted on each reindex; /// any other process opens fresh per query so it reflects the writer's latest /// commit. A missing index is not built here — call `oxcode_watch` first. - async fn index_for( - &self, - path: Option, - peer: &Peer, - ) -> Result, McpError> { - let root = self.resolve_root(path, peer).await?; + async fn index_for(&self, path: Option) -> Result, McpError> { + let root = self.resolve_root(path).await?; if self.is_writer(&root) { if let Some(index) = self.indexes.lock().await.get(&root) { return Ok(Arc::clone(index)); @@ -598,11 +597,12 @@ impl OxcodeServer { /// signals over this process's cwd. MCP hosts often start servers with /// `cwd=$HOME` even when the agent is in a project folder; omitting `path` /// must not silently index the home directory. - async fn resolve_root( - &self, - path: Option, - peer: &Peer, - ) -> Result { + /// + /// Never calls `roots/list` here — nested client requests during tool handling + /// can hang on some hosts. Roots are fetched in [`Self::fetch_client_roots`] + /// from `on_initialized` / `on_roots_list_changed` only; this waits briefly + /// for that in-flight fetch when the cache is still cold. + async fn resolve_root(&self, path: Option) -> Result { let explicit = path .as_deref() .map(str::trim) @@ -612,7 +612,7 @@ impl OxcodeServer { explicit_path } else if let Some(from_env) = env_project_root() { from_env - } else if let Some(from_roots) = self.workspace_root_from_client(peer).await { + } else if let Some(from_roots) = self.workspace_root_after_ready().await { from_roots } else { std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) @@ -633,42 +633,52 @@ impl OxcodeServer { Ok(root) } - /// First client MCP root, refreshing the cache via `roots/list` when needed. - async fn workspace_root_from_client(&self, peer: &Peer) -> Option { - { - let cache = self - .client_roots - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some(roots) = cache.as_ref() { - return roots.first().cloned(); - } + /// First cached client MCP root, waiting briefly for the init-time fetch. + async fn workspace_root_after_ready(&self) -> Option { + if let Some(root) = self.cached_workspace_root() { + return Some(root); } - peer.peer_info() - .and_then(|info| info.capabilities.roots.as_ref())?; - let roots = match peer.list_roots().await { - Ok(result) => result - .roots - .iter() - .filter_map(|root| file_uri_to_path(&root.uri)) - .collect::>(), - Err(_) => return None, - }; - let first = roots.first().cloned(); - *self - .client_roots - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(roots); - first + if !*self.roots_ready.borrow() { + let mut ready = self.roots_ready.clone(); + let _ = tokio::time::timeout(ROOTS_READY_WAIT, ready.wait_for(|ready| *ready)).await; + } + self.cached_workspace_root() } - /// Refreshes the cached client MCP roots after `notifications/roots/list_changed`. - async fn refresh_client_roots(&self, peer: &Peer) { - *self - .client_roots + /// First non-empty client MCP root already cached from init / list_changed. + fn cached_workspace_root(&self) -> Option { + self.client_roots .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = None; - let _ = self.workspace_root_from_client(peer).await; + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .and_then(|roots| roots.first().cloned()) + } + + /// Fetches `roots/list` outside tool handling and updates the cache. + /// + /// On failure, leaves the previous cache untouched (so a transient error does + /// not erase a good root). An empty successful list clears the cache to `None` + /// rather than `Some([])`, so a later `roots/list_changed` can populate it + /// without looking like a settled empty answer forever. Always marks + /// [`Self::roots_ready`] so tool handlers can proceed. + async fn fetch_client_roots(&self, peer: &Peer) { + let supports_roots = peer + .peer_info() + .and_then(|info| info.capabilities.roots.as_ref()) + .is_some(); + if supports_roots && let Ok(result) = peer.list_roots().await { + let roots: Vec = result + .roots + .iter() + .filter_map(|root| file_uri_to_path(&root.uri)) + .collect(); + let cached = (!roots.is_empty()).then_some(roots); + *self + .client_roots + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = cached; + } + let _ = self.roots_ready_tx.send(true); } } @@ -780,8 +790,12 @@ impl ServerHandler for OxcodeServer { .with_instructions(INSTRUCTIONS) } + async fn on_initialized(&self, context: NotificationContext) { + self.fetch_client_roots(&context.peer).await; + } + async fn on_roots_list_changed(&self, context: NotificationContext) { - self.refresh_client_roots(&context.peer).await; + self.fetch_client_roots(&context.peer).await; } } @@ -1251,7 +1265,8 @@ mod tests { .await .expect("client connect"); - // No `path` argument: must resolve via roots/list to the project, not cwd. + // No `path` argument: `on_initialized` already cached roots/list; resolve + // must use that workspace root (not process cwd) without nested roots/list. let result = client .call_tool(tool_call("oxcode_watch", serde_json::json!({}))) .await From 5d5941ab6fe833a745297c744e924af7d50e968a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 03:53:51 +0000 Subject: [PATCH 05/11] fix(mcp): wait for in-flight roots refetches; keep cache on parse miss Cold resolves wait on the fetch lock so roots/list_changed updates are observed. Explicitly empty client lists clear the cache; unparseable URI payloads leave a previously good root in place. Co-authored-by: Michael Assaf --- crates/oxcode-cli/src/mcp.rs | 64 ++++++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/crates/oxcode-cli/src/mcp.rs b/crates/oxcode-cli/src/mcp.rs index 94af366..ceb05dc 100644 --- a/crates/oxcode-cli/src/mcp.rs +++ b/crates/oxcode-cli/src/mcp.rs @@ -25,7 +25,7 @@ use oxcode_core::{GraphDirection, IndexStats, NodeKind, ProjectIndex}; use rmcp::{ ErrorData as McpError, Peer, RoleServer, ServerHandler, ServiceExt, handler::server::{router::tool::ToolRouter, wrapper::Parameters}, - model::{CallToolResult, Content, ServerCapabilities, ServerInfo, TasksCapability}, + model::{CallToolResult, Content, Root, ServerCapabilities, ServerInfo, TasksCapability}, schemars, service::NotificationContext, task_handler, @@ -115,9 +115,12 @@ pub(crate) struct OxcodeServer { /// Client MCP roots (`roots/list`), cached so omitted `path` defaults to the /// workspace rather than this process's cwd (often `$HOME` under MCP hosts). client_roots: Arc>>>, - /// Becomes `true` after the first init-time roots fetch attempt finishes (or - /// is skipped because the client has no roots capability). Tools wait on this - /// instead of issuing nested `roots/list` calls. + /// Held for the duration of every `roots/list` fetch (init or list_changed). + /// Cold tool resolves acquire it briefly so they observe in-flight updates + /// without issuing nested `roots/list` themselves. + roots_fetch: Arc>, + /// Becomes `true` after the first fetch attempt finishes (or is skipped). + /// Distinguishes "init not started yet" from "fetch done, cache still empty". roots_ready: tokio::sync::watch::Receiver, /// Sender half for [`Self::roots_ready`]. roots_ready_tx: Arc>, @@ -244,6 +247,7 @@ impl OxcodeServer { writers: Arc::new(std::sync::Mutex::new(HashMap::new())), standbys: Arc::new(std::sync::Mutex::new(HashSet::new())), client_roots: Arc::new(std::sync::Mutex::new(None)), + roots_fetch: Arc::new(Mutex::new(())), roots_ready, roots_ready_tx: Arc::new(roots_ready_tx), debounce, @@ -633,14 +637,19 @@ impl OxcodeServer { Ok(root) } - /// First cached client MCP root, waiting briefly for the init-time fetch. + /// First cached client MCP root, waiting briefly for any in-flight fetch + /// (init or `roots/list_changed`) when the cache is still cold. async fn workspace_root_after_ready(&self) -> Option { if let Some(root) = self.cached_workspace_root() { return Some(root); } if !*self.roots_ready.borrow() { + // Init fetch has not finished (may not have started): wait for it. let mut ready = self.roots_ready.clone(); let _ = tokio::time::timeout(ROOTS_READY_WAIT, ready.wait_for(|ready| *ready)).await; + } else { + // A later `roots/list_changed` refetch may be in flight — wait it out. + let _ = tokio::time::timeout(ROOTS_READY_WAIT, self.roots_fetch.lock()).await; } self.cached_workspace_root() } @@ -656,29 +665,42 @@ impl OxcodeServer { /// Fetches `roots/list` outside tool handling and updates the cache. /// - /// On failure, leaves the previous cache untouched (so a transient error does - /// not erase a good root). An empty successful list clears the cache to `None` - /// rather than `Some([])`, so a later `roots/list_changed` can populate it - /// without looking like a settled empty answer forever. Always marks - /// [`Self::roots_ready`] so tool handlers can proceed. + /// RPC failures and unparseable URI lists leave the previous cache untouched. + /// An explicitly empty `roots` array from the client clears the cache. async fn fetch_client_roots(&self, peer: &Peer) { - let supports_roots = peer + let _guard = self.roots_fetch.lock().await; + if peer .peer_info() .and_then(|info| info.capabilities.roots.as_ref()) - .is_some(); - if supports_roots && let Ok(result) = peer.list_roots().await { - let roots: Vec = result - .roots - .iter() - .filter_map(|root| file_uri_to_path(&root.uri)) - .collect(); - let cached = (!roots.is_empty()).then_some(roots); + .is_some() + && let Ok(result) = peer.list_roots().await + { + self.apply_roots_list(result.roots); + } + let _ = self.roots_ready_tx.send(true); + } + + /// Applies a `roots/list` payload to [`Self::client_roots`]. + fn apply_roots_list(&self, roots: Vec) { + if roots.is_empty() { *self .client_roots .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = cached; + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + return; } - let _ = self.roots_ready_tx.send(true); + let parsed: Vec = roots + .iter() + .filter_map(|root| file_uri_to_path(&root.uri)) + .collect(); + if parsed.is_empty() { + // URIs present but none parseable — keep the previous good root. + return; + } + *self + .client_roots + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(parsed); } } From 0a7a93e373e231d53d3d295c5b1a91beeec8d1a8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 03:57:30 +0000 Subject: [PATCH 06/11] fix(mcp): wait out in-flight roots refetch before using cache When roots/list_changed is running, cold and warm omitted-path resolves wait on the fetch lock so tools do not keep using the previous workspace. Co-authored-by: Michael Assaf --- crates/oxcode-cli/src/mcp.rs | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/crates/oxcode-cli/src/mcp.rs b/crates/oxcode-cli/src/mcp.rs index ceb05dc..096ec86 100644 --- a/crates/oxcode-cli/src/mcp.rs +++ b/crates/oxcode-cli/src/mcp.rs @@ -638,18 +638,28 @@ impl OxcodeServer { } /// First cached client MCP root, waiting briefly for any in-flight fetch - /// (init or `roots/list_changed`) when the cache is still cold. + /// (init or `roots/list_changed`) so a folder switch is not served from a + /// stale cache. async fn workspace_root_after_ready(&self) -> Option { - if let Some(root) = self.cached_workspace_root() { - return Some(root); + match self.roots_fetch.try_lock() { + Ok(_guard) => { + // No fetch in flight — use the cache if warm. + if let Some(root) = self.cached_workspace_root() { + return Some(root); + } + } + Err(_) => { + // Fetch in flight — wait for the updated cache before resolving. + let _ = tokio::time::timeout(ROOTS_READY_WAIT, self.roots_fetch.lock()).await; + if let Some(root) = self.cached_workspace_root() { + return Some(root); + } + } } if !*self.roots_ready.borrow() { // Init fetch has not finished (may not have started): wait for it. let mut ready = self.roots_ready.clone(); let _ = tokio::time::timeout(ROOTS_READY_WAIT, ready.wait_for(|ready| *ready)).await; - } else { - // A later `roots/list_changed` refetch may be in flight — wait it out. - let _ = tokio::time::timeout(ROOTS_READY_WAIT, self.roots_fetch.lock()).await; } self.cached_workspace_root() } From aac5d17115e8cc4156e6af35cade2d157ddb42a2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 04:10:41 +0000 Subject: [PATCH 07/11] refactor(mcp): extract project-root/roots modules; fix Thermos findings Split path defaulting and RootsCache (Pending/Ready watch) out of the MCP server module. On fetch timeout skip stale cache; parse Windows file:///C:/... roots; flatten OptionalProjectRoot docs; keep prior root on unparseable URI lists. Co-authored-by: Michael Assaf --- crates/oxcode-cli/src/mcp.rs | 1525 ----------------- .../oxcode-cli/src/mcp/integration_tests.rs | 491 ++++++ crates/oxcode-cli/src/mcp/mod.rs | 761 ++++++++ crates/oxcode-cli/src/mcp/project_root.rs | 232 +++ crates/oxcode-cli/src/mcp/roots.rs | 147 ++ 5 files changed, 1631 insertions(+), 1525 deletions(-) delete mode 100644 crates/oxcode-cli/src/mcp.rs create mode 100644 crates/oxcode-cli/src/mcp/integration_tests.rs create mode 100644 crates/oxcode-cli/src/mcp/mod.rs create mode 100644 crates/oxcode-cli/src/mcp/project_root.rs create mode 100644 crates/oxcode-cli/src/mcp/roots.rs diff --git a/crates/oxcode-cli/src/mcp.rs b/crates/oxcode-cli/src/mcp.rs deleted file mode 100644 index 096ec86..0000000 --- a/crates/oxcode-cli/src/mcp.rs +++ /dev/null @@ -1,1525 +0,0 @@ -//! The `oxcode mcp` server: tools mapped onto `oxcode_core::ProjectIndex`. -//! -//! Exposes oxcode's read-only queries plus a single-writer file watcher -//! (`oxcode_watch`) to coding agents over MCP (stdio). Run it with `oxcode mcp`; -//! configure your agent to launch that command. Across many MCP processes pointed -//! at one folder, a `.oxcode/watch.lock` file lock elects exactly one writer (the -//! process that watches and re-indexes); the rest serve reads. - -use std::{ - collections::{HashMap, HashSet}, - fs::{File, OpenOptions, TryLockError}, - path::{Path, PathBuf}, - sync::{ - Arc, - atomic::{AtomicU64, Ordering}, - }, - time::Duration, -}; - -use notify_debouncer_full::{ - DebounceEventResult, Debouncer, RecommendedCache, new_debouncer, - notify::{RecommendedWatcher, RecursiveMode}, -}; -use oxcode_core::{GraphDirection, IndexStats, NodeKind, ProjectIndex}; -use rmcp::{ - ErrorData as McpError, Peer, RoleServer, ServerHandler, ServiceExt, - handler::server::{router::tool::ToolRouter, wrapper::Parameters}, - model::{CallToolResult, Content, Root, ServerCapabilities, ServerInfo, TasksCapability}, - schemars, - service::NotificationContext, - task_handler, - task_manager::OperationProcessor, - tool, tool_handler, tool_router, - transport::stdio, -}; -use serde::Deserialize; -use tokio::sync::{ - Mutex, - mpsc::{UnboundedReceiver, unbounded_channel}, -}; - -/// Default debounce window for the file watcher: collapse an editor's save burst -/// (write + rename of a temp file, etc.) into one re-index. Overridable with -/// `OXCODE_WATCH_DEBOUNCE_MS`. -const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(400); - -/// Default failover poll interval: how often a standby retries the writer lock so -/// it can take over when the current writer exits. Overridable with -/// `OXCODE_WATCH_POLL_MS`. -const DEFAULT_POLL: Duration = Duration::from_secs(3); - -/// How long an omitted-`path` resolve will wait for the in-flight -/// `on_initialized` roots fetch before falling through to cwd. Never starts a -/// nested `roots/list` from a tool handler. -const ROOTS_READY_WAIT: Duration = Duration::from_millis(500); - -/// Filename of the advisory single-writer lock, inside the `.oxcode` index dir. -const WATCH_LOCK_FILE: &str = "watch.lock"; - -/// Directory names whose filesystem events never warrant a re-index: the index -/// store itself (`.oxcode`, the load-bearing entry that prevents a write → -/// event → re-index feedback loop) plus the dirs source discovery already -/// skips. Mirrors `oxcode_core`'s scan skip list. -const WATCH_SKIP_DIRS: &[&str] = &[".oxcode", ".git", "target", "node_modules", "vendor"]; - -/// Runs the MCP server over stdio until the client disconnects. The index is not -/// touched until a client calls `oxcode_watch` (writer) or queries (reader). -pub(crate) fn serve() -> anyhow::Result<()> { - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build()?; - runtime.block_on(async { - let service = OxcodeServer::new().serve(stdio()).await?; - service.waiting().await?; - Ok(()) - }) -} - -/// Server instructions steering agents to `oxcode_watch` then `oxcode_explore`. -const INSTRUCTIONS: &str = "This server answers questions about the code repository in the current \ -project. First call `oxcode_watch` (optional `path`): it builds the index if needed and keeps it \ -current as files change. When `path` is omitted, the project root is taken from OXCODE_ROOT, \ -CLAUDE_PROJECT_DIR, WORKSPACE_FOLDER_PATHS, or the client's MCP roots — not from this process's \ -cwd, which MCP hosts often set to $HOME. Pass `path` explicitly when in doubt. Only one MCP \ -instance watches a given folder at a time — a file lock elects a single writer; other instances \ -serve reads and take over automatically if the writer exits. Then, for almost any \ -code-understanding question, call `oxcode_explore` first with the user's question verbatim: it \ -returns the most relevant symbols (ranked by graph centrality), their source, the relationships \ -among them, the n-ary hyperedges they belong to (trait impl groups and container/module membership, \ -ranked by hypergraph PageRank — the architecture-altitude layer), the blast radius, and the call \ -flow — in one call. Use `oxcode_callers`/`oxcode_callees`/`oxcode_symbol` to follow specific edges, \ -and `oxcode_search`/`oxcode_files` only when explore did not surface the target. Prefer these query \ -tools over shelling out to grep or reading files. Every tool except `oxcode_watch` is read-only; do \ -not edit source files."; - -/// MCP server over oxcode's read-only queries plus the `oxcode_watch` file -/// watcher. Caches one opened index per root it writes, elects a single writer -/// per root via a file lock, and drives task-augmented calls through an -/// [`OperationProcessor`]. -#[derive(Clone)] -pub(crate) struct OxcodeServer { - #[expect( - dead_code, - reason = "stored per rmcp's #[tool_router] convention; the #[tool_handler]-generated request router reads it through macro-expanded code the dead-code pass does not attribute" - )] - tool_router: ToolRouter, - /// Opened readers cached per root this process writes (evicted on reindex). - indexes: Arc>>>, - /// Backs the rmcp `#[task_handler]` lifecycle for task-augmented tool calls. - operations: Arc>, - /// Roots this process is the elected writer for (holds the lock + watcher). - writers: Arc>>>, - /// Roots this process is a standby for (lost the lock; a failover task polls). - standbys: Arc>>, - /// Client MCP roots (`roots/list`), cached so omitted `path` defaults to the - /// workspace rather than this process's cwd (often `$HOME` under MCP hosts). - client_roots: Arc>>>, - /// Held for the duration of every `roots/list` fetch (init or list_changed). - /// Cold tool resolves acquire it briefly so they observe in-flight updates - /// without issuing nested `roots/list` themselves. - roots_fetch: Arc>, - /// Becomes `true` after the first fetch attempt finishes (or is skipped). - /// Distinguishes "init not started yet" from "fetch done, cache still empty". - roots_ready: tokio::sync::watch::Receiver, - /// Sender half for [`Self::roots_ready`]. - roots_ready_tx: Arc>, - /// File-watcher debounce window. - debounce: Duration, - /// Failover poll interval for standbys. - poll: Duration, -} - -/// State for a root this process has been elected to write. Dropping it (on -/// process exit) releases the advisory lock and stops the watcher. -struct WriterState { - /// Held advisory `flock`; the kernel frees it on drop or process crash, so a - /// standby can take over. The file itself is never removed. - _lock_file: File, - /// Live debouncer; dropping it stops the watch thread. The `std::sync::Mutex` - /// makes `WriterState: Sync` regardless of the platform watcher's `Sync`-ness. - /// `None` when the watcher failed to start (the lock still elects this writer). - _watcher: std::sync::Mutex>>, - /// Number of reindexes this process has performed for the root (observability). - reindexes: Arc, -} - -/// A code question to answer in one curated call. -#[derive(Debug, Deserialize, schemars::JsonSchema)] -pub(crate) struct ExploreParams { - /// The task or question about the codebase, in natural language. - pub query: String, - /// Project root; defaults to the workspace (OXCODE_ROOT / CLAUDE_PROJECT_DIR / - /// WORKSPACE_FOLDER_PATHS / MCP roots), never silently to `$HOME`. - pub path: Option, - /// Maximum source characters to render (default 20000). - pub max_bytes: Option, -} - -/// A keyword search over indexed symbols. -#[derive(Debug, Deserialize, schemars::JsonSchema)] -pub(crate) struct SearchParams { - /// Keywords matched against symbol names, signatures, and docs. - pub query: String, - /// Project root; defaults to the workspace (OXCODE_ROOT / CLAUDE_PROJECT_DIR / - /// WORKSPACE_FOLDER_PATHS / MCP roots), never silently to `$HOME`. - pub path: Option, - /// Maximum number of matches (default 30). - pub limit: Option, - /// Restrict to these symbol kinds (e.g. function, method, struct, trait). - pub kinds: Option>, -} - -/// A call-graph query around one symbol selector. -#[derive(Debug, Deserialize, schemars::JsonSchema)] -pub(crate) struct CallParams { - /// Selector: a qualified name, `name:`, `element:`, or `file::`. - pub selector: String, - /// Project root; defaults to the workspace (OXCODE_ROOT / CLAUDE_PROJECT_DIR / - /// WORKSPACE_FOLDER_PATHS / MCP roots), never silently to `$HOME`. - pub path: Option, - /// Maximum hop depth (default 2). - pub depth: Option, - /// Maximum discovered symbol count (default 50). - pub limit: Option, -} - -/// One symbol selector to describe. -#[derive(Debug, Deserialize, schemars::JsonSchema)] -pub(crate) struct SymbolParams { - /// Selector: a qualified name, `name:`, `element:`, or `file::`. - pub selector: String, - /// Project root; defaults to the workspace (OXCODE_ROOT / CLAUDE_PROJECT_DIR / - /// WORKSPACE_FOLDER_PATHS / MCP roots), never silently to `$HOME`. - pub path: Option, -} - -/// A keyword search over indexed files. -#[derive(Debug, Deserialize, schemars::JsonSchema)] -pub(crate) struct FilesParams { - /// Keywords matched against file paths and their symbols. - pub query: String, - /// Project root; defaults to the workspace (OXCODE_ROOT / CLAUDE_PROJECT_DIR / - /// WORKSPACE_FOLDER_PATHS / MCP roots), never silently to `$HOME`. - pub path: Option, - /// Maximum number of files (default 30). - pub limit: Option, -} - -/// A project-root pointer. -#[derive(Debug, Deserialize, schemars::JsonSchema)] -pub(crate) struct StatusParams { - /// Project root; defaults to the workspace (OXCODE_ROOT / CLAUDE_PROJECT_DIR / - /// WORKSPACE_FOLDER_PATHS / MCP roots), never silently to `$HOME`. - pub path: Option, -} - -/// A project root to watch and keep indexed. -#[derive(Debug, Deserialize, schemars::JsonSchema)] -pub(crate) struct WatchParams { - /// Project root to watch; defaults to the workspace (OXCODE_ROOT / - /// CLAUDE_PROJECT_DIR / WORKSPACE_FOLDER_PATHS / MCP roots), never silently to - /// `$HOME`. - pub path: Option, -} - -#[tool_router] -impl OxcodeServer { - /// Builds a server with intervals from the environment (or defaults). Nothing - /// is indexed or watched until a client calls `oxcode_watch` or queries. - #[must_use] - pub(crate) fn new() -> Self { - Self::new_with( - env_duration("OXCODE_WATCH_DEBOUNCE_MS", DEFAULT_DEBOUNCE), - env_duration("OXCODE_WATCH_POLL_MS", DEFAULT_POLL), - ) - } - - /// Builds a server with explicit debounce + failover-poll windows (tests use - /// tiny values). - #[must_use] - fn new_with(debounce: Duration, poll: Duration) -> Self { - let (roots_ready_tx, roots_ready) = tokio::sync::watch::channel(false); - Self { - tool_router: Self::tool_router(), - indexes: Arc::new(Mutex::new(HashMap::new())), - operations: Arc::new(Mutex::new(OperationProcessor::new())), - writers: Arc::new(std::sync::Mutex::new(HashMap::new())), - standbys: Arc::new(std::sync::Mutex::new(HashSet::new())), - client_roots: Arc::new(std::sync::Mutex::new(None)), - roots_fetch: Arc::new(Mutex::new(())), - roots_ready, - roots_ready_tx: Arc::new(roots_ready_tx), - debounce, - poll, - } - } - - #[tool( - description = "Start (or join) watching a project so its index is built and kept current as files change. Exactly one MCP instance per folder becomes the writer (it holds a file lock and re-indexes on changes); other instances become readers that just serve queries and automatically take over if the writer exits. Call this once before querying. Optional `path` defaults to the workspace (OXCODE_ROOT / CLAUDE_PROJECT_DIR / WORKSPACE_FOLDER_PATHS / MCP roots); never silently to $HOME.", - execution(task_support = "optional") - )] - async fn oxcode_watch( - &self, - Parameters(params): Parameters, - ) -> Result { - let root = self.resolve_root(params.path).await?; - - // Idempotent: already participating for this root. - if self.is_writer(&root) { - return json_result(&watch_body(&root, "writer", true, None)); - } - if self.is_standby(&root) { - return json_result(&watch_body(&root, "standby", false, None)); - } - - // The lock lives inside `.oxcode/`, which `.gitignore`s itself. - let index_directory = oxcode_core::index_dir(&root); - ensure_index_dir(&index_directory) - .map_err(|error| McpError::internal_error(error.to_string(), None))?; - let lock_file = OpenOptions::new() - .create(true) - .truncate(false) - .read(true) - .write(true) - .open(index_directory.join(WATCH_LOCK_FILE)) - .map_err(|error| McpError::internal_error(format!("open watch lock: {error}"), None))?; - - match lock_file.try_lock() { - Ok(()) => { - let stats = self - .promote_to_writer(root.clone(), lock_file) - .await - .map_err(|error| McpError::internal_error(error.to_string(), None))?; - eprintln!("oxcode: elected as writer for {}", root.display()); - json_result(&watch_body(&root, "writer", true, Some(&stats))) - } - Err(TryLockError::WouldBlock) => { - self.standbys - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert(root.clone()); - tokio::spawn(self.clone().failover_loop(root.clone(), lock_file)); - eprintln!( - "oxcode: standby — another instance is watching {}", - root.display() - ); - json_result(&watch_body(&root, "standby", false, None)) - } - Err(TryLockError::Error(error)) => Err(McpError::internal_error( - format!("acquire watch lock: {error}"), - None, - )), - } - } - - #[tool( - description = "Answer a code question in one call: returns the most relevant symbols ranked by graph centrality, their source, relationships, n-ary hyperedges (trait impl groups and container membership, ranked by hypergraph PageRank for architecture-altitude questions), blast radius, and call flow for the query. Use this first for any code-understanding question.", - execution(task_support = "optional") - )] - async fn oxcode_explore( - &self, - Parameters(params): Parameters, - ) -> Result { - let index = self.index_for(params.path).await?; - let query = params.query; - let max_bytes = params.max_bytes.unwrap_or(20_000); - let report = blocking(move || index.context(&query, 8, 1, max_bytes)).await?; - json_result(&report) - } - - #[tool( - description = "Search indexed symbols by keyword, optionally restricted to symbol kinds." - )] - async fn oxcode_search( - &self, - Parameters(params): Parameters, - ) -> Result { - let index = self.index_for(params.path).await?; - let query = params.query; - let limit = params.limit.unwrap_or(30); - let kinds = parse_kinds(params.kinds.as_deref()); - let report = blocking(move || index.search_symbols_filtered(&query, limit, &kinds)).await?; - json_result(&report) - } - - #[tool(description = "Find the functions that call the given symbol (incoming call graph).")] - async fn oxcode_callers( - &self, - Parameters(params): Parameters, - ) -> Result { - self.call_graph(params, GraphDirection::Incoming).await - } - - #[tool(description = "Find the functions called by the given symbol (outgoing call graph).")] - async fn oxcode_callees( - &self, - Parameters(params): Parameters, - ) -> Result { - self.call_graph(params, GraphDirection::Outgoing).await - } - - #[tool( - description = "Describe one symbol by selector (qualified name, name:, element:, or file::)." - )] - async fn oxcode_symbol( - &self, - Parameters(params): Parameters, - ) -> Result { - let index = self.index_for(params.path).await?; - let selector = params.selector; - let value = blocking(move || resolve_symbol(&index, &selector)).await?; - json_result(&value) - } - - #[tool(description = "Search indexed files by keyword.")] - async fn oxcode_files( - &self, - Parameters(params): Parameters, - ) -> Result { - let index = self.index_for(params.path).await?; - let query = params.query; - let limit = params.limit.unwrap_or(30); - let report = blocking(move || index.search_files(&query, limit)).await?; - json_result(&report) - } - - #[tool( - description = "Show the project's database status (element/relation counts, paths) plus this instance's watch role (writer/standby/reader) and how many times it has re-indexed." - )] - async fn oxcode_status( - &self, - Parameters(params): Parameters, - ) -> Result { - let root = self.resolve_root(params.path).await?; - let (role, watching, reindexes) = self.watch_state(&root); - let status_root = root.clone(); - let database = blocking(move || oxcode_core::project_status(&status_root)).await?; - let body = serde_json::json!({ - "watch": { "role": role, "watching": watching, "reindexes": reindexes }, - "database": database, - }); - json_result(&body) - } - - /// Shared call-graph path for callers/callees. - async fn call_graph( - &self, - params: CallParams, - direction: GraphDirection, - ) -> Result { - let index = self.index_for(params.path).await?; - let selector = params.selector; - let depth = params.depth.unwrap_or(2); - let limit = params.limit.unwrap_or(50); - let report = blocking(move || index.call_graph(&selector, direction, depth, limit)).await?; - json_result(&report) - } - - /// Opens the index for `path` (default: workspace root). If this process is the - /// writer for the root, the opened reader is cached and evicted on each reindex; - /// any other process opens fresh per query so it reflects the writer's latest - /// commit. A missing index is not built here — call `oxcode_watch` first. - async fn index_for(&self, path: Option) -> Result, McpError> { - let root = self.resolve_root(path).await?; - if self.is_writer(&root) { - if let Some(index) = self.indexes.lock().await.get(&root) { - return Ok(Arc::clone(index)); - } - let open_root = root.clone(); - let index = Arc::new(blocking(move || ProjectIndex::open(&open_root)).await?); - self.indexes.lock().await.insert(root, Arc::clone(&index)); - return Ok(index); - } - if !oxcode_core::database_dir(&root).exists() { - return Err(McpError::invalid_params( - format!( - "no index yet for {} — call oxcode_watch to build and keep it current", - root.display() - ), - None, - )); - } - // Reader: open fresh so the writer's latest committed snapshot is visible. - let open_root = root.clone(); - Ok(Arc::new( - blocking(move || ProjectIndex::open(&open_root)).await?, - )) - } - - /// Builds/refreshes `root`, starts its watcher, and records this process as the - /// writer. Caller must already hold the advisory lock (`lock_file`). - async fn promote_to_writer( - &self, - root: PathBuf, - lock_file: File, - ) -> anyhow::Result { - let write_lock = Arc::new(Mutex::new(())); - let reindexes = Arc::new(AtomicU64::new(0)); - let stats = run_reindex(&self.indexes, &root, &write_lock, &reindexes).await?; - let watcher = self.spawn_watch(&root, write_lock, Arc::clone(&reindexes)); - let state = Arc::new(WriterState { - _lock_file: lock_file, - _watcher: std::sync::Mutex::new(watcher), - reindexes, - }); - self.writers - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert(root.clone(), state); - self.standbys - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .remove(&root); - Ok(stats) - } - - /// Failover: poll the writer lock; when the current writer exits and frees it, - /// promote this process to writer (build + watch). Runs until promotion. - async fn failover_loop(self, root: PathBuf, lock_file: File) { - loop { - tokio::time::sleep(self.poll).await; - if self.is_writer(&root) { - break; - } - match lock_file.try_lock() { - Ok(()) => { - self.take_over(root, lock_file).await; - break; - } - Err(TryLockError::WouldBlock) => continue, - Err(TryLockError::Error(error)) => { - eprintln!( - "oxcode: failover lock error for {}: {error}", - root.display() - ); - break; - } - } - } - } - - /// Promotes this process to writer for `root` after winning the freed lock, - /// logging the outcome to stderr. - async fn take_over(&self, root: PathBuf, lock_file: File) { - match self.promote_to_writer(root.clone(), lock_file).await { - Ok(_) => eprintln!( - "oxcode: promoted to writer after previous writer released {}", - root.display() - ), - Err(error) => { - eprintln!( - "oxcode: failover index failed for {}: {error}", - root.display() - ) - } - } - } - - /// Starts a recursive debounced watcher on `root` and a task that re-indexes - /// (serialized by `write_lock`) on each debounced change. Returns `None` if the - /// watcher could not be started. - fn spawn_watch( - &self, - root: &Path, - write_lock: Arc>, - reindexes: Arc, - ) -> Option> { - let (tick_tx, tick_rx) = unbounded_channel::<()>(); - let mut debouncer = - match new_debouncer(self.debounce, None, move |result: DebounceEventResult| { - // Tick on any batch that touches at least one indexable path. A - // batch confined to skip dirs (notably `.oxcode/`, which our own - // re-index writes) is dropped — this is what breaks the feedback - // loop. Watcher errors are transient; the next real event re-syncs. - if let Ok(events) = result - && events - .iter() - .flat_map(|event| event.paths.iter()) - .any(|path| !is_ignored_path(path)) - { - let _ = tick_tx.send(()); - } - }) { - Ok(debouncer) => debouncer, - Err(error) => { - eprintln!( - "oxcode: file watcher unavailable for {}: {error}", - root.display() - ); - return None; - } - }; - if let Err(error) = debouncer.watch(root, RecursiveMode::Recursive) { - eprintln!("oxcode: cannot watch {}: {error}", root.display()); - return None; - } - tokio::spawn(watch_loop( - Arc::clone(&self.indexes), - root.to_path_buf(), - write_lock, - reindexes, - tick_rx, - )); - Some(debouncer) - } - - /// Whether this process is the elected writer for `root`. - fn is_writer(&self, root: &Path) -> bool { - self.writers - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .contains_key(root) - } - - /// Whether this process is a standby (failover participant) for `root`. - fn is_standby(&self, root: &Path) -> bool { - self.standbys - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .contains(root) - } - - /// This process's role for `root`, plus whether it is watching and its reindex - /// count (0 for non-writers). - fn watch_state(&self, root: &Path) -> (&'static str, bool, u64) { - if let Some(state) = self - .writers - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .get(root) - { - return ("writer", true, state.reindexes.load(Ordering::Relaxed)); - } - if self.is_standby(root) { - return ("standby", false, 0); - } - ("reader", false, 0) - } - - /// Resolves the project root from an optional `path`, preferring workspace - /// signals over this process's cwd. MCP hosts often start servers with - /// `cwd=$HOME` even when the agent is in a project folder; omitting `path` - /// must not silently index the home directory. - /// - /// Never calls `roots/list` here — nested client requests during tool handling - /// can hang on some hosts. Roots are fetched in [`Self::fetch_client_roots`] - /// from `on_initialized` / `on_roots_list_changed` only; this waits briefly - /// for that in-flight fetch when the cache is still cold. - async fn resolve_root(&self, path: Option) -> Result { - let explicit = path - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(PathBuf::from); - let raw = if let Some(explicit_path) = explicit.clone() { - explicit_path - } else if let Some(from_env) = env_project_root() { - from_env - } else if let Some(from_roots) = self.workspace_root_after_ready().await { - from_roots - } else { - std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) - }; - let root = canonicalize_root(raw); - if explicit.is_none() && is_home_directory(&root) { - return Err(McpError::invalid_params( - format!( - "refusing to use home directory {} as the project root — MCP hosts often \ - start this server with cwd=$HOME even when your workspace is elsewhere. \ - Pass `path` (the project folder), or set OXCODE_ROOT / CLAUDE_PROJECT_DIR \ - / WORKSPACE_FOLDER_PATHS.", - root.display() - ), - None, - )); - } - Ok(root) - } - - /// First cached client MCP root, waiting briefly for any in-flight fetch - /// (init or `roots/list_changed`) so a folder switch is not served from a - /// stale cache. - async fn workspace_root_after_ready(&self) -> Option { - match self.roots_fetch.try_lock() { - Ok(_guard) => { - // No fetch in flight — use the cache if warm. - if let Some(root) = self.cached_workspace_root() { - return Some(root); - } - } - Err(_) => { - // Fetch in flight — wait for the updated cache before resolving. - let _ = tokio::time::timeout(ROOTS_READY_WAIT, self.roots_fetch.lock()).await; - if let Some(root) = self.cached_workspace_root() { - return Some(root); - } - } - } - if !*self.roots_ready.borrow() { - // Init fetch has not finished (may not have started): wait for it. - let mut ready = self.roots_ready.clone(); - let _ = tokio::time::timeout(ROOTS_READY_WAIT, ready.wait_for(|ready| *ready)).await; - } - self.cached_workspace_root() - } - - /// First non-empty client MCP root already cached from init / list_changed. - fn cached_workspace_root(&self) -> Option { - self.client_roots - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .as_ref() - .and_then(|roots| roots.first().cloned()) - } - - /// Fetches `roots/list` outside tool handling and updates the cache. - /// - /// RPC failures and unparseable URI lists leave the previous cache untouched. - /// An explicitly empty `roots` array from the client clears the cache. - async fn fetch_client_roots(&self, peer: &Peer) { - let _guard = self.roots_fetch.lock().await; - if peer - .peer_info() - .and_then(|info| info.capabilities.roots.as_ref()) - .is_some() - && let Ok(result) = peer.list_roots().await - { - self.apply_roots_list(result.roots); - } - let _ = self.roots_ready_tx.send(true); - } - - /// Applies a `roots/list` payload to [`Self::client_roots`]. - fn apply_roots_list(&self, roots: Vec) { - if roots.is_empty() { - *self - .client_roots - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = None; - return; - } - let parsed: Vec = roots - .iter() - .filter_map(|root| file_uri_to_path(&root.uri)) - .collect(); - if parsed.is_empty() { - // URIs present but none parseable — keep the previous good root. - return; - } - *self - .client_roots - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(parsed); - } -} - -/// Re-indexes `root` on each debounced change tick until the watcher stops. -async fn watch_loop( - indexes: Arc>>>, - root: PathBuf, - write_lock: Arc>, - reindexes: Arc, - mut tick_rx: UnboundedReceiver<()>, -) { - while tick_rx.recv().await.is_some() { - // Collapse a burst of ticks that landed during the last re-index into one run. - while tick_rx.try_recv().is_ok() {} - match run_reindex(&indexes, &root, &write_lock, &reindexes).await { - Ok(_) => eprintln!( - "oxcode: re-indexed {} (#{})", - root.display(), - reindexes.load(Ordering::Relaxed) - ), - Err(error) => eprintln!("oxcode: re-index failed for {}: {error}", root.display()), - } - } -} - -/// Runs `index_project` for `root` under `write_lock` (serializing this process's -/// writers), evicts the cached reader so the next query reopens the fresh index, -/// and bumps the reindex counter. An unchanged tree is a cheap digest no-op. -async fn run_reindex( - indexes: &Arc>>>, - root: &Path, - write_lock: &Mutex<()>, - reindexes: &AtomicU64, -) -> anyhow::Result { - let _guard = write_lock.lock().await; - let root_owned = root.to_path_buf(); - let stats = - tokio::task::spawn_blocking(move || oxcode_core::index_project(&root_owned)).await??; - // Bump the counter before evicting the cache: the eviction is what lets a - // concurrent reader observe the new commit, so ordering the increment first - // guarantees "new symbol visible" implies "reindex counted". - reindexes.fetch_add(1, Ordering::Relaxed); - indexes.lock().await.remove(root); - Ok(stats) -} - -/// Whether a changed path falls in a directory source discovery skips, so its -/// events should not trigger a re-index. Mirrors `oxcode_core`'s scan skip list; -/// `.oxcode/` is the load-bearing entry that prevents a self-triggered loop. -fn is_ignored_path(path: &Path) -> bool { - path.components().any(|component| { - matches!(component, std::path::Component::Normal(name) - if WATCH_SKIP_DIRS.iter().any(|skip| name == std::ffi::OsStr::new(skip))) - }) -} - -/// Creates the `.oxcode` index dir and its self-ignoring `.gitignore` so the lock -/// file is never committed. Idempotent. -fn ensure_index_dir(index_directory: &Path) -> std::io::Result<()> { - std::fs::create_dir_all(index_directory)?; - let gitignore = index_directory.join(".gitignore"); - if !gitignore.exists() { - std::fs::write(&gitignore, "*\n")?; - } - Ok(()) -} - -/// Reads a millisecond duration from `key`, falling back to `default`. -fn env_duration(key: &str, default: Duration) -> Duration { - std::env::var(key) - .ok() - .and_then(|value| value.parse::().ok()) - .map(Duration::from_millis) - .unwrap_or(default) -} - -/// Builds the JSON body for an `oxcode_watch` response. -fn watch_body( - root: &Path, - role: &str, - watching: bool, - stats: Option<&IndexStats>, -) -> serde_json::Value { - let mut body = serde_json::json!({ - "root": root.display().to_string(), - "role": role, - "watching": watching, - }); - if let Some(stats) = stats { - body["index"] = serde_json::to_value(stats).unwrap_or(serde_json::Value::Null); - } else if !watching { - body["message"] = serde_json::json!( - "another oxcode instance is watching this root; standing by to take over if it exits" - ); - } - body -} - -#[tool_handler] -#[task_handler(processor = self.operations)] -impl ServerHandler for OxcodeServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new( - ServerCapabilities::builder() - .enable_tools() - .enable_tasks_with(TasksCapability::server_default()) - .build(), - ) - .with_instructions(INSTRUCTIONS) - } - - async fn on_initialized(&self, context: NotificationContext) { - self.fetch_client_roots(&context.peer).await; - } - - async fn on_roots_list_changed(&self, context: NotificationContext) { - self.fetch_client_roots(&context.peer).await; - } -} - -/// Project root from host-injected environment variables, in priority order. -/// -/// MCP hosts frequently leave the server process cwd at `$HOME` while advertising -/// the real workspace via these variables (Claude Code → `CLAUDE_PROJECT_DIR`, -/// Cursor → `WORKSPACE_FOLDER_PATHS`). `OXCODE_ROOT` is the explicit override. -fn env_project_root() -> Option { - for key in ["OXCODE_ROOT", "CLAUDE_PROJECT_DIR"] { - if let Ok(value) = std::env::var(key) { - let trimmed = value.trim(); - if !trimmed.is_empty() { - return Some(PathBuf::from(trimmed)); - } - } - } - std::env::var("WORKSPACE_FOLDER_PATHS") - .ok() - .and_then(|value| first_workspace_folder(&value)) -} - -/// First folder from a `WORKSPACE_FOLDER_PATHS` value (single path or CSV). -fn first_workspace_folder(value: &str) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() { - return None; - } - let as_path = PathBuf::from(trimmed); - if as_path.is_dir() { - return Some(as_path); - } - // Multi-root workspaces: comma-separated. Do not split on `:` — that breaks - // Windows drive letters (`C:\...`). - trimmed - .split(',') - .map(str::trim) - .find(|part| !part.is_empty()) - .map(PathBuf::from) -} - -/// Whether `path` is the current user's home directory (best-effort). -fn is_home_directory(path: &Path) -> bool { - let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) else { - return false; - }; - let home = PathBuf::from(home); - canonicalize_root(path.to_path_buf()) == canonicalize_root(home) -} - -/// Canonicalizes best-effort so reader cache / writer registry / lock file key on -/// the same absolute path (FS events report canonical paths). Falls back to the -/// raw path when it does not exist yet. -fn canonicalize_root(path: PathBuf) -> PathBuf { - std::fs::canonicalize(&path).unwrap_or(path) -} - -/// Converts a `file://` MCP root URI into a filesystem path. -fn file_uri_to_path(uri: &str) -> Option { - let rest = uri.strip_prefix("file://")?; - let path = if let Some(path) = rest.strip_prefix("localhost") { - path - } else if rest.starts_with('/') { - rest - } else { - return None; - }; - let decoded = percent_decode(path); - if decoded.is_empty() { - return None; - } - Some(PathBuf::from(decoded)) -} - -/// Decodes `%XX` sequences in a URI path; returns the input unchanged when none. -fn percent_decode(input: &str) -> String { - if !input.contains('%') { - return input.to_owned(); - } - let bytes = input.as_bytes(); - let mut out = Vec::with_capacity(bytes.len()); - let mut index = 0; - while index < bytes.len() { - if bytes[index] == b'%' - && index + 2 < bytes.len() - && let (Some(high), Some(low)) = - (hex_nibble(bytes[index + 1]), hex_nibble(bytes[index + 2])) - { - out.push((high << 4) | low); - index += 3; - continue; - } - out.push(bytes[index]); - index += 1; - } - String::from_utf8_lossy(&out).into_owned() -} - -fn hex_nibble(byte: u8) -> Option { - match byte { - b'0'..=b'9' => Some(byte - b'0'), - b'a'..=b'f' => Some(byte - b'a' + 10), - b'A'..=b'F' => Some(byte - b'A' + 10), - _ => None, - } -} - -/// Parses caller-supplied kind strings into `NodeKind`, dropping unknown ones. -fn parse_kinds(kinds: Option<&[String]>) -> Vec { - kinds - .unwrap_or_default() - .iter() - .filter_map(|kind| NodeKind::try_from(kind.as_str()).ok()) - .collect() -} - -/// Resolves a selector to a single symbol, or a structured ambiguous/not-found value. -fn resolve_symbol(index: &ProjectIndex, selector: &str) -> oxcode_core::Result { - let value = match index.resolve_selector(selector)?.as_slice() { - [single] => serde_json::json!({ "status": "matched", "symbol": single }), - [] => serde_json::json!({ "status": "not_found", "selector": selector, "matches": [] }), - matches => { - serde_json::json!({ "status": "ambiguous", "selector": selector, "matches": matches }) - } - }; - Ok(value) -} - -/// Runs a blocking oxcode read on the blocking pool, mapping errors to MCP errors. -async fn blocking(f: F) -> Result -where - T: Send + 'static, - F: FnOnce() -> oxcode_core::Result + Send + 'static, -{ - tokio::task::spawn_blocking(f) - .await - .map_err(|error| McpError::internal_error(format!("oxcode task failed: {error}"), None))? - .map_err(|error| McpError::internal_error(error.to_string(), None)) -} - -/// Serializes a report into one JSON text content block. -fn json_result(value: &T) -> Result { - let text = serde_json::to_string(value) - .map_err(|error| McpError::internal_error(format!("serialize failed: {error}"), None))?; - Ok(CallToolResult::success(vec![Content::text(text)])) -} - -#[cfg(test)] -mod tests { - //! In-process integration tests: a real `OxcodeServer` and an MCP client wired - //! over `tokio::io::duplex`, exercising the full JSON-RPC stack. These cover - //! tool registration, writer election + the read path, auto re-index on change, - //! and the task lifecycle. The cross-process guarantee is proven separately by - //! `tests/multiprocess.rs` (real spawned processes). - - use std::{ - sync::{Mutex, MutexGuard}, - time::Duration, - }; - - use rmcp::{ - ClientHandler, RoleClient, - model::{ - CallToolRequestParams, ClientCapabilities, ClientInfo, ClientRequest, - GetTaskInfoParams, GetTaskResultParams, Implementation, ListRootsResult, Request, Root, - ServerResult, TaskStatus, TaskSupport, - }, - service::{RequestContext, RunningService}, - }; - - use super::*; - - /// Serializes tests that mutate process-global project-root env vars. - static PROJECT_ROOT_ENV_LOCK: Mutex<()> = Mutex::new(()); - - /// Env keys consulted by [`env_project_root`], for save/restore around tests. - const PROJECT_ROOT_ENV_KEYS: &[&str] = &[ - "OXCODE_ROOT", - "CLAUDE_PROJECT_DIR", - "WORKSPACE_FOLDER_PATHS", - ]; - - /// Clears project-root env vars for the duration of a test; restores on drop. - struct ProjectRootEnvGuard { - _lock: MutexGuard<'static, ()>, - previous: Vec<(&'static str, Option)>, - } - - impl ProjectRootEnvGuard { - fn clear() -> Self { - let lock = PROJECT_ROOT_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let previous = PROJECT_ROOT_ENV_KEYS - .iter() - .map(|&key| (key, std::env::var_os(key))) - .collect::>(); - // SAFETY: held exclusively via PROJECT_ROOT_ENV_LOCK for this process. - unsafe { - for key in PROJECT_ROOT_ENV_KEYS { - std::env::remove_var(key); - } - } - Self { - _lock: lock, - previous, - } - } - - fn set(&self, key: &str, value: impl AsRef) { - // SAFETY: guard holds PROJECT_ROOT_ENV_LOCK. - unsafe { - std::env::set_var(key, value); - } - } - } - - impl Drop for ProjectRootEnvGuard { - fn drop(&mut self) { - // SAFETY: guard still holds PROJECT_ROOT_ENV_LOCK until drop completes. - unsafe { - for (key, value) in self.previous.drain(..) { - match value { - Some(value) => std::env::set_var(key, value), - None => std::env::remove_var(key), - } - } - } - } - } - - /// Minimal MCP client; the server is what these tests exercise. - #[derive(Clone, Default)] - struct TestClient; - - impl ClientHandler for TestClient {} - - /// MCP client that advertises workspace roots (Cursor / Claude Code do this). - #[derive(Clone)] - struct RootsClient { - roots: Vec, - } - - impl ClientHandler for RootsClient { - fn get_info(&self) -> ClientInfo { - ClientInfo::new( - ClientCapabilities::builder().enable_roots().build(), - Implementation::from_build_env(), - ) - } - - fn list_roots( - &self, - _context: RequestContext, - ) -> impl std::future::Future> + Send + '_ - { - let roots = self - .roots - .iter() - .map(|path| Root::new(format!("file://{}", path.display()))) - .collect(); - std::future::ready(Ok(ListRootsResult::new(roots))) - } - } - - /// Wires a fresh `OxcodeServer` (with the given intervals) to a `TestClient` - /// over an in-memory duplex pipe and returns the connected client service. - async fn connect(debounce: Duration, poll: Duration) -> RunningService { - let (server_transport, client_transport) = tokio::io::duplex(4096); - tokio::spawn(async move { - let server = OxcodeServer::new_with(debounce, poll) - .serve(server_transport) - .await - .expect("server serve"); - let _ = server.waiting().await; - }); - TestClient - .serve(client_transport) - .await - .expect("client connect") - } - - /// Writes a minimal two-function Rust project into a fresh temp dir. - fn rust_project() -> tempfile::TempDir { - let temp = tempfile::TempDir::new().expect("temp dir"); - std::fs::create_dir_all(temp.path().join("src")).expect("mkdir src"); - std::fs::write( - temp.path().join("src/lib.rs"), - "pub fn helper() {}\npub fn entry() {\n helper();\n}\n", - ) - .expect("write lib.rs"); - temp - } - - /// Builds a tool-call params object for `name` with JSON `arguments`. - fn tool_call(name: &'static str, arguments: serde_json::Value) -> CallToolRequestParams { - let mut params = CallToolRequestParams::new(name); - params.arguments = arguments.as_object().cloned(); - params - } - - /// Extracts the single text content block from a tool result. - fn result_text(result: &CallToolResult) -> &str { - result - .content - .first() - .and_then(|content| content.as_text()) - .map(|text| text.text.as_str()) - .expect("text content") - } - - /// Calls `oxcode_watch` for `path` and returns the parsed JSON response. - async fn watch( - client: &RunningService, - path: &str, - ) -> serde_json::Value { - let result = client - .call_tool(tool_call( - "oxcode_watch", - serde_json::json!({ "path": path }), - )) - .await - .expect("watch call"); - serde_json::from_str(result_text(&result)).expect("watch json") - } - - /// Polls `oxcode_search` (bounded) until `name` actually appears as a match. - /// Inspects the parsed `matches` array — not a substring of the JSON, which - /// would falsely match the echoed `query` field. - async fn poll_symbol_indexed( - client: &RunningService, - path: &str, - name: &str, - ) -> bool { - for _ in 0..100 { - let searched = client - .call_tool(tool_call( - "oxcode_search", - serde_json::json!({ "path": path, "query": name }), - )) - .await - .expect("search call"); - let report: serde_json::Value = - serde_json::from_str(result_text(&searched)).expect("search json"); - // Keyword search is fuzzy, so check for an exact-named match rather - // than "any match" (which would falsely fire on weak candidates). - let matched = report["matches"] - .as_array() - .is_some_and(|matches| matches.iter().any(|entry| entry["symbol"]["name"] == name)); - if matched { - return true; - } - tokio::time::sleep(Duration::from_millis(50)).await; - } - false - } - - /// Polls `tasks/get` until the task reaches a terminal status (or times out). - async fn poll_until_terminal( - client: &RunningService, - task_id: &str, - ) -> TaskStatus { - let mut status = TaskStatus::Working; - for _ in 0..200 { - let info = client - .send_request(ClientRequest::GetTaskInfoRequest(Request::new( - GetTaskInfoParams { - meta: None, - task_id: task_id.to_owned(), - }, - ))) - .await - .expect("tasks/get"); - if let ServerResult::GetTaskResult(result) = info { - status = result.task.status; - } - if status != TaskStatus::Working { - break; - } - tokio::time::sleep(Duration::from_millis(20)).await; - } - status - } - - #[test] - fn env_project_root_prefers_oxcode_root() { - let project = tempfile::TempDir::new().expect("temp"); - let guard = ProjectRootEnvGuard::clear(); - guard.set("OXCODE_ROOT", project.path()); - let resolved = env_project_root().expect("OXCODE_ROOT"); - assert_eq!( - canonicalize_root(resolved), - canonicalize_root(project.path().to_path_buf()) - ); - } - - #[test] - fn first_workspace_folder_accepts_csv_and_single_path() { - let project = tempfile::TempDir::new().expect("temp"); - let path = project.path().to_string_lossy().into_owned(); - assert_eq!( - first_workspace_folder(&path), - Some(PathBuf::from(&path)), - "existing single path wins without splitting" - ); - assert_eq!( - first_workspace_folder(&format!("{path},/does/not/exist")), - Some(PathBuf::from(&path)) - ); - assert_eq!( - first_workspace_folder("/missing/a,/missing/b"), - Some(PathBuf::from("/missing/a")) - ); - } - - #[test] - fn file_uri_to_path_decodes_file_roots() { - assert_eq!( - file_uri_to_path("file:///Users/snowmead/opt/jinttai"), - Some(PathBuf::from("/Users/snowmead/opt/jinttai")) - ); - assert_eq!( - file_uri_to_path("file://localhost/tmp/project%20name"), - Some(PathBuf::from("/tmp/project name")) - ); - assert_eq!(file_uri_to_path("https://example.com"), None); - } - - #[test] - fn is_home_directory_matches_home_env() { - let home = tempfile::TempDir::new().expect("home"); - // Reuse the project-root env lock so HOME mutations never race other tests. - let _guard = ProjectRootEnvGuard::clear(); - let previous_home = std::env::var_os("HOME"); - // SAFETY: PROJECT_ROOT_ENV_LOCK is held via `_guard`. - unsafe { - std::env::set_var("HOME", home.path()); - } - assert!(is_home_directory(home.path())); - assert!(!is_home_directory(&home.path().join("opt/jinttai"))); - // SAFETY: restore HOME before `_guard` drops and releases the lock. - unsafe { - match previous_home { - Some(value) => std::env::set_var("HOME", value), - None => std::env::remove_var("HOME"), - } - } - } - - #[tokio::test] - async fn omitted_path_uses_client_mcp_roots_not_process_cwd() { - let _env = ProjectRootEnvGuard::clear(); - let project = rust_project(); - let (server_transport, client_transport) = tokio::io::duplex(4096); - tokio::spawn(async move { - let server = - OxcodeServer::new_with(Duration::from_millis(50), Duration::from_millis(150)) - .serve(server_transport) - .await - .expect("server serve"); - let _ = server.waiting().await; - }); - let client = RootsClient { - roots: vec![project.path().to_path_buf()], - } - .serve(client_transport) - .await - .expect("client connect"); - - // No `path` argument: `on_initialized` already cached roots/list; resolve - // must use that workspace root (not process cwd) without nested roots/list. - let result = client - .call_tool(tool_call("oxcode_watch", serde_json::json!({}))) - .await - .expect("watch without path"); - let body: serde_json::Value = - serde_json::from_str(result_text(&result)).expect("watch json"); - assert_eq!(body["role"], "writer"); - assert_eq!( - canonicalize_root(PathBuf::from(body["root"].as_str().expect("root"))), - canonicalize_root(project.path().to_path_buf()), - "omitted path must use the client's MCP root, not the server process cwd" - ); - } - - /// `flock` is per open-file-description on macOS/Linux: a second independent - /// open of the same path cannot take the lock the first holds. This pins the - /// platform behavior the writer election depends on. - #[test] - fn watch_lock_is_exclusive_per_handle() { - let temp = tempfile::TempDir::new().expect("temp dir"); - let path = temp.path().join("watch.lock"); - let first = OpenOptions::new() - .create(true) - .truncate(false) - .read(true) - .write(true) - .open(&path) - .expect("open first"); - first.try_lock().expect("first acquires"); - let second = OpenOptions::new() - .read(true) - .write(true) - .open(&path) - .expect("open second"); - assert!( - matches!(second.try_lock(), Err(TryLockError::WouldBlock)), - "a second handle cannot take the held lock" - ); - } - - #[tokio::test] - async fn lists_tools_with_watch_and_explore_task_support() { - let client = connect(DEFAULT_DEBOUNCE, DEFAULT_POLL).await; - let tools = client.list_all_tools().await.expect("list tools"); - - assert!( - tools.iter().any(|tool| tool.name == "oxcode_watch"), - "oxcode_watch is registered" - ); - assert!( - tools.iter().all(|tool| tool.name != "oxcode_index"), - "the old write tool is gone" - ); - - let task_support = |name: &str| { - tools - .iter() - .find(|tool| tool.name == name) - .and_then(|tool| tool.execution.as_ref()) - .and_then(|execution| execution.task_support) - }; - assert_eq!(task_support("oxcode_watch"), Some(TaskSupport::Optional)); - assert_eq!(task_support("oxcode_explore"), Some(TaskSupport::Optional)); - assert_eq!(task_support("oxcode_search"), None); - assert_eq!(task_support("oxcode_status"), None); - } - - #[tokio::test] - async fn watch_elects_writer_and_serves_queries() { - let project = rust_project(); - let path = project.path().to_string_lossy().into_owned(); - let client = connect(Duration::from_millis(50), Duration::from_millis(150)).await; - - let watched = watch(&client, &path).await; - assert_eq!(watched["role"], "writer", "first watcher is the writer"); - assert_eq!(watched["watching"], true); - - let explored = client - .call_tool(tool_call( - "oxcode_explore", - serde_json::json!({ "path": path, "query": "entry" }), - )) - .await - .expect("explore call"); - assert!( - result_text(&explored).contains("entry"), - "writer's index is queryable" - ); - } - - #[tokio::test] - async fn second_watcher_on_same_root_is_standby() { - let project = rust_project(); - let path = project.path().to_string_lossy().into_owned(); - let writer_client = connect(Duration::from_millis(50), Duration::from_millis(150)).await; - let standby_client = connect(Duration::from_millis(50), Duration::from_millis(150)).await; - - assert_eq!(watch(&writer_client, &path).await["role"], "writer"); - // Second server, same root: the lock is held, so it becomes a standby. - assert_eq!(watch(&standby_client, &path).await["role"], "standby"); - - // The standby still answers queries off the shared on-disk index. - let explored = standby_client - .call_tool(tool_call( - "oxcode_explore", - serde_json::json!({ "path": path, "query": "entry" }), - )) - .await - .expect("reader explore"); - assert!(result_text(&explored).contains("entry")); - } - - #[tokio::test] - async fn query_without_watch_errors_when_no_index() { - let project = rust_project(); - let path = project.path().to_string_lossy().into_owned(); - let client = connect(DEFAULT_DEBOUNCE, DEFAULT_POLL).await; - - // No oxcode_watch, no prior index: a query must not build; it hints instead. - let result = client - .call_tool(tool_call( - "oxcode_explore", - serde_json::json!({ "path": path, "query": "entry" }), - )) - .await; - assert!( - result.is_err(), - "query before oxcode_watch errors with a hint, never silently builds" - ); - } - - #[tokio::test] - async fn writer_auto_reindexes_on_change() { - let project = rust_project(); - let path = project.path().to_string_lossy().into_owned(); - let client = connect(Duration::from_millis(50), Duration::from_millis(150)).await; - - assert_eq!(watch(&client, &path).await["role"], "writer"); - - // Let the FS-event stream establish before the change: FSEvents (and - // other backends) have a startup window where a change can land as - // initial state and go unreported. - tokio::time::sleep(Duration::from_millis(300)).await; - std::fs::write( - project.path().join("src/extra.rs"), - "pub fn brand_new_symbol() {}\n", - ) - .expect("write extra.rs"); - - let found = poll_symbol_indexed(&client, &path, "brand_new_symbol").await; - assert!( - found, - "the writer's watcher re-indexed and surfaced the symbol" - ); - - let status: serde_json::Value = serde_json::from_str(result_text( - &client - .call_tool(tool_call( - "oxcode_status", - serde_json::json!({ "path": path }), - )) - .await - .expect("status call"), - )) - .expect("status json"); - assert_eq!(status["watch"]["role"], "writer"); - assert!( - status["watch"]["reindexes"].as_u64().unwrap_or(0) >= 2, - "writer reindexed at least the initial build and the change" - ); - } - - #[tokio::test] - async fn task_augmented_watch_completes() { - let project = rust_project(); - let path = project.path().to_string_lossy().into_owned(); - let client = connect(Duration::from_millis(50), Duration::from_millis(150)).await; - - // Task-augment the call: typed `call_tool` cannot carry a task field, so - // send the request directly and expect an immediate CreateTaskResult. - let mut params = tool_call("oxcode_watch", serde_json::json!({ "path": path })); - params.task = serde_json::json!({ "ttl": 60_000 }).as_object().cloned(); - let created = client - .send_request(ClientRequest::CallToolRequest(Request::new(params))) - .await - .expect("enqueue task"); - let task_id = match created { - ServerResult::CreateTaskResult(result) => { - assert_eq!(result.task.status, TaskStatus::Working); - result.task.task_id - } - other => panic!("expected CreateTaskResult, got {other:?}"), - }; - - let status = poll_until_terminal(&client, &task_id).await; - assert_eq!( - status, - TaskStatus::Completed, - "watch task ran to completion" - ); - - let payload = client - .send_request(ClientRequest::GetTaskResultRequest(Request::new( - GetTaskResultParams { - meta: None, - task_id, - }, - ))) - .await - .expect("tasks/result"); - let text = match payload { - ServerResult::CallToolResult(result) => result_text(&result).to_owned(), - ServerResult::GetTaskPayloadResult(payload) => payload.0["content"][0]["text"] - .as_str() - .expect("tool result text") - .to_owned(), - other => panic!("expected the deferred tool result, got {other:?}"), - }; - assert!( - text.contains("writer"), - "deferred watch result reports the elected writer role" - ); - } -} diff --git a/crates/oxcode-cli/src/mcp/integration_tests.rs b/crates/oxcode-cli/src/mcp/integration_tests.rs new file mode 100644 index 0000000..c7fade1 --- /dev/null +++ b/crates/oxcode-cli/src/mcp/integration_tests.rs @@ -0,0 +1,491 @@ +//! In-process MCP integration tests for OxcodeServer. + +//! In-process integration tests: a real `OxcodeServer` and an MCP client wired +//! over `tokio::io::duplex`, exercising the full JSON-RPC stack. These cover +//! tool registration, writer election + the read path, auto re-index on change, +//! and the task lifecycle. The cross-process guarantee is proven separately by +//! `tests/multiprocess.rs` (real spawned processes). + +use std::{ + sync::{Mutex, MutexGuard}, + time::Duration, +}; + +use rmcp::{ + ClientHandler, RoleClient, + model::{ + CallToolRequestParams, ClientCapabilities, ClientInfo, ClientRequest, GetTaskInfoParams, + GetTaskResultParams, Implementation, ListRootsResult, Request, Root, ServerResult, + TaskStatus, TaskSupport, + }, + service::{RequestContext, RunningService}, +}; + +use super::{ + project_root::{canonicalize_root, env_project_root}, + *, +}; + +/// Serializes tests that mutate process-global project-root env vars. +static PROJECT_ROOT_ENV_LOCK: Mutex<()> = Mutex::new(()); + +/// Env keys consulted by [`env_project_root`], for save/restore around tests. +const PROJECT_ROOT_ENV_KEYS: &[&str] = &[ + "OXCODE_ROOT", + "CLAUDE_PROJECT_DIR", + "WORKSPACE_FOLDER_PATHS", +]; + +/// Clears project-root env vars for the duration of a test; restores on drop. +struct ProjectRootEnvGuard { + _lock: MutexGuard<'static, ()>, + previous: Vec<(&'static str, Option)>, +} + +impl ProjectRootEnvGuard { + fn clear() -> Self { + let lock = PROJECT_ROOT_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let previous = PROJECT_ROOT_ENV_KEYS + .iter() + .map(|&key| (key, std::env::var_os(key))) + .collect::>(); + // SAFETY: held exclusively via PROJECT_ROOT_ENV_LOCK for this process. + unsafe { + for key in PROJECT_ROOT_ENV_KEYS { + std::env::remove_var(key); + } + } + Self { + _lock: lock, + previous, + } + } + + fn set(&self, key: &str, value: impl AsRef) { + // SAFETY: guard holds PROJECT_ROOT_ENV_LOCK. + unsafe { + std::env::set_var(key, value); + } + } +} + +impl Drop for ProjectRootEnvGuard { + fn drop(&mut self) { + // SAFETY: guard still holds PROJECT_ROOT_ENV_LOCK until drop completes. + unsafe { + for (key, value) in self.previous.drain(..) { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + } +} + +/// Minimal MCP client; the server is what these tests exercise. +#[derive(Clone, Default)] +struct TestClient; + +impl ClientHandler for TestClient {} + +/// MCP client that advertises workspace roots (Cursor / Claude Code do this). +#[derive(Clone)] +struct RootsClient { + roots: Vec, +} + +impl ClientHandler for RootsClient { + fn get_info(&self) -> ClientInfo { + ClientInfo::new( + ClientCapabilities::builder().enable_roots().build(), + Implementation::from_build_env(), + ) + } + + fn list_roots( + &self, + _context: RequestContext, + ) -> impl std::future::Future> + Send + '_ { + let roots = self + .roots + .iter() + .map(|path| Root::new(format!("file://{}", path.display()))) + .collect(); + std::future::ready(Ok(ListRootsResult::new(roots))) + } +} + +/// Wires a fresh `OxcodeServer` (with the given intervals) to a `TestClient` +/// over an in-memory duplex pipe and returns the connected client service. +async fn connect(debounce: Duration, poll: Duration) -> RunningService { + let (server_transport, client_transport) = tokio::io::duplex(4096); + tokio::spawn(async move { + let server = OxcodeServer::new_with(debounce, poll) + .serve(server_transport) + .await + .expect("server serve"); + let _ = server.waiting().await; + }); + TestClient + .serve(client_transport) + .await + .expect("client connect") +} + +/// Writes a minimal two-function Rust project into a fresh temp dir. +fn rust_project() -> tempfile::TempDir { + let temp = tempfile::TempDir::new().expect("temp dir"); + std::fs::create_dir_all(temp.path().join("src")).expect("mkdir src"); + std::fs::write( + temp.path().join("src/lib.rs"), + "pub fn helper() {}\npub fn entry() {\n helper();\n}\n", + ) + .expect("write lib.rs"); + temp +} + +/// Builds a tool-call params object for `name` with JSON `arguments`. +fn tool_call(name: &'static str, arguments: serde_json::Value) -> CallToolRequestParams { + let mut params = CallToolRequestParams::new(name); + params.arguments = arguments.as_object().cloned(); + params +} + +/// Extracts the single text content block from a tool result. +fn result_text(result: &CallToolResult) -> &str { + result + .content + .first() + .and_then(|content| content.as_text()) + .map(|text| text.text.as_str()) + .expect("text content") +} + +/// Calls `oxcode_watch` for `path` and returns the parsed JSON response. +async fn watch(client: &RunningService, path: &str) -> serde_json::Value { + let result = client + .call_tool(tool_call( + "oxcode_watch", + serde_json::json!({ "path": path }), + )) + .await + .expect("watch call"); + serde_json::from_str(result_text(&result)).expect("watch json") +} + +/// Polls `oxcode_search` (bounded) until `name` actually appears as a match. +/// Inspects the parsed `matches` array — not a substring of the JSON, which +/// would falsely match the echoed `query` field. +async fn poll_symbol_indexed( + client: &RunningService, + path: &str, + name: &str, +) -> bool { + for _ in 0..100 { + let searched = client + .call_tool(tool_call( + "oxcode_search", + serde_json::json!({ "path": path, "query": name }), + )) + .await + .expect("search call"); + let report: serde_json::Value = + serde_json::from_str(result_text(&searched)).expect("search json"); + // Keyword search is fuzzy, so check for an exact-named match rather + // than "any match" (which would falsely fire on weak candidates). + let matched = report["matches"] + .as_array() + .is_some_and(|matches| matches.iter().any(|entry| entry["symbol"]["name"] == name)); + if matched { + return true; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + false +} + +/// Polls `tasks/get` until the task reaches a terminal status (or times out). +async fn poll_until_terminal( + client: &RunningService, + task_id: &str, +) -> TaskStatus { + let mut status = TaskStatus::Working; + for _ in 0..200 { + let info = client + .send_request(ClientRequest::GetTaskInfoRequest(Request::new( + GetTaskInfoParams { + meta: None, + task_id: task_id.to_owned(), + }, + ))) + .await + .expect("tasks/get"); + if let ServerResult::GetTaskResult(result) = info { + status = result.task.status; + } + if status != TaskStatus::Working { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + status +} + +#[test] +fn env_project_root_prefers_oxcode_root() { + let project = tempfile::TempDir::new().expect("temp"); + let guard = ProjectRootEnvGuard::clear(); + guard.set("OXCODE_ROOT", project.path()); + let resolved = env_project_root().expect("OXCODE_ROOT"); + assert_eq!( + canonicalize_root(resolved), + canonicalize_root(project.path().to_path_buf()) + ); +} + +#[tokio::test] +async fn omitted_path_uses_client_mcp_roots_not_process_cwd() { + let _env = ProjectRootEnvGuard::clear(); + let project = rust_project(); + let (server_transport, client_transport) = tokio::io::duplex(4096); + tokio::spawn(async move { + let server = OxcodeServer::new_with(Duration::from_millis(50), Duration::from_millis(150)) + .serve(server_transport) + .await + .expect("server serve"); + let _ = server.waiting().await; + }); + let client = RootsClient { + roots: vec![project.path().to_path_buf()], + } + .serve(client_transport) + .await + .expect("client connect"); + + // No `path` argument: `on_initialized` already cached roots/list; resolve + // must use that workspace root (not process cwd) without nested roots/list. + let result = client + .call_tool(tool_call("oxcode_watch", serde_json::json!({}))) + .await + .expect("watch without path"); + let body: serde_json::Value = serde_json::from_str(result_text(&result)).expect("watch json"); + assert_eq!(body["role"], "writer"); + assert_eq!( + canonicalize_root(PathBuf::from(body["root"].as_str().expect("root"))), + canonicalize_root(project.path().to_path_buf()), + "omitted path must use the client's MCP root, not the server process cwd" + ); +} + +/// `flock` is per open-file-description on macOS/Linux: a second independent +/// open of the same path cannot take the lock the first holds. This pins the +/// platform behavior the writer election depends on. +#[test] +fn watch_lock_is_exclusive_per_handle() { + let temp = tempfile::TempDir::new().expect("temp dir"); + let path = temp.path().join("watch.lock"); + let first = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&path) + .expect("open first"); + first.try_lock().expect("first acquires"); + let second = OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .expect("open second"); + assert!( + matches!(second.try_lock(), Err(TryLockError::WouldBlock)), + "a second handle cannot take the held lock" + ); +} + +#[tokio::test] +async fn lists_tools_with_watch_and_explore_task_support() { + let client = connect(DEFAULT_DEBOUNCE, DEFAULT_POLL).await; + let tools = client.list_all_tools().await.expect("list tools"); + + assert!( + tools.iter().any(|tool| tool.name == "oxcode_watch"), + "oxcode_watch is registered" + ); + assert!( + tools.iter().all(|tool| tool.name != "oxcode_index"), + "the old write tool is gone" + ); + + let task_support = |name: &str| { + tools + .iter() + .find(|tool| tool.name == name) + .and_then(|tool| tool.execution.as_ref()) + .and_then(|execution| execution.task_support) + }; + assert_eq!(task_support("oxcode_watch"), Some(TaskSupport::Optional)); + assert_eq!(task_support("oxcode_explore"), Some(TaskSupport::Optional)); + assert_eq!(task_support("oxcode_search"), None); + assert_eq!(task_support("oxcode_status"), None); +} + +#[tokio::test] +async fn watch_elects_writer_and_serves_queries() { + let project = rust_project(); + let path = project.path().to_string_lossy().into_owned(); + let client = connect(Duration::from_millis(50), Duration::from_millis(150)).await; + + let watched = watch(&client, &path).await; + assert_eq!(watched["role"], "writer", "first watcher is the writer"); + assert_eq!(watched["watching"], true); + + let explored = client + .call_tool(tool_call( + "oxcode_explore", + serde_json::json!({ "path": path, "query": "entry" }), + )) + .await + .expect("explore call"); + assert!( + result_text(&explored).contains("entry"), + "writer's index is queryable" + ); +} + +#[tokio::test] +async fn second_watcher_on_same_root_is_standby() { + let project = rust_project(); + let path = project.path().to_string_lossy().into_owned(); + let writer_client = connect(Duration::from_millis(50), Duration::from_millis(150)).await; + let standby_client = connect(Duration::from_millis(50), Duration::from_millis(150)).await; + + assert_eq!(watch(&writer_client, &path).await["role"], "writer"); + // Second server, same root: the lock is held, so it becomes a standby. + assert_eq!(watch(&standby_client, &path).await["role"], "standby"); + + // The standby still answers queries off the shared on-disk index. + let explored = standby_client + .call_tool(tool_call( + "oxcode_explore", + serde_json::json!({ "path": path, "query": "entry" }), + )) + .await + .expect("reader explore"); + assert!(result_text(&explored).contains("entry")); +} + +#[tokio::test] +async fn query_without_watch_errors_when_no_index() { + let project = rust_project(); + let path = project.path().to_string_lossy().into_owned(); + let client = connect(DEFAULT_DEBOUNCE, DEFAULT_POLL).await; + + // No oxcode_watch, no prior index: a query must not build; it hints instead. + let result = client + .call_tool(tool_call( + "oxcode_explore", + serde_json::json!({ "path": path, "query": "entry" }), + )) + .await; + assert!( + result.is_err(), + "query before oxcode_watch errors with a hint, never silently builds" + ); +} + +#[tokio::test] +async fn writer_auto_reindexes_on_change() { + let project = rust_project(); + let path = project.path().to_string_lossy().into_owned(); + let client = connect(Duration::from_millis(50), Duration::from_millis(150)).await; + + assert_eq!(watch(&client, &path).await["role"], "writer"); + + // Let the FS-event stream establish before the change: FSEvents (and + // other backends) have a startup window where a change can land as + // initial state and go unreported. + tokio::time::sleep(Duration::from_millis(300)).await; + std::fs::write( + project.path().join("src/extra.rs"), + "pub fn brand_new_symbol() {}\n", + ) + .expect("write extra.rs"); + + let found = poll_symbol_indexed(&client, &path, "brand_new_symbol").await; + assert!( + found, + "the writer's watcher re-indexed and surfaced the symbol" + ); + + let status: serde_json::Value = serde_json::from_str(result_text( + &client + .call_tool(tool_call( + "oxcode_status", + serde_json::json!({ "path": path }), + )) + .await + .expect("status call"), + )) + .expect("status json"); + assert_eq!(status["watch"]["role"], "writer"); + assert!( + status["watch"]["reindexes"].as_u64().unwrap_or(0) >= 2, + "writer reindexed at least the initial build and the change" + ); +} + +#[tokio::test] +async fn task_augmented_watch_completes() { + let project = rust_project(); + let path = project.path().to_string_lossy().into_owned(); + let client = connect(Duration::from_millis(50), Duration::from_millis(150)).await; + + // Task-augment the call: typed `call_tool` cannot carry a task field, so + // send the request directly and expect an immediate CreateTaskResult. + let mut params = tool_call("oxcode_watch", serde_json::json!({ "path": path })); + params.task = serde_json::json!({ "ttl": 60_000 }).as_object().cloned(); + let created = client + .send_request(ClientRequest::CallToolRequest(Request::new(params))) + .await + .expect("enqueue task"); + let task_id = match created { + ServerResult::CreateTaskResult(result) => { + assert_eq!(result.task.status, TaskStatus::Working); + result.task.task_id + } + other => panic!("expected CreateTaskResult, got {other:?}"), + }; + + let status = poll_until_terminal(&client, &task_id).await; + assert_eq!( + status, + TaskStatus::Completed, + "watch task ran to completion" + ); + + let payload = client + .send_request(ClientRequest::GetTaskResultRequest(Request::new( + GetTaskResultParams { + meta: None, + task_id, + }, + ))) + .await + .expect("tasks/result"); + let text = match payload { + ServerResult::CallToolResult(result) => result_text(&result).to_owned(), + ServerResult::GetTaskPayloadResult(payload) => payload.0["content"][0]["text"] + .as_str() + .expect("tool result text") + .to_owned(), + other => panic!("expected the deferred tool result, got {other:?}"), + }; + assert!( + text.contains("writer"), + "deferred watch result reports the elected writer role" + ); +} diff --git a/crates/oxcode-cli/src/mcp/mod.rs b/crates/oxcode-cli/src/mcp/mod.rs new file mode 100644 index 0000000..cfec448 --- /dev/null +++ b/crates/oxcode-cli/src/mcp/mod.rs @@ -0,0 +1,761 @@ +//! The `oxcode mcp` server: tools mapped onto `oxcode_core::ProjectIndex`. +//! +//! Exposes oxcode's read-only queries plus a single-writer file watcher +//! (`oxcode_watch`) to coding agents over MCP (stdio). Run it with `oxcode mcp`; +//! configure your agent to launch that command. Across many MCP processes pointed +//! at one folder, a `.oxcode/watch.lock` file lock elects exactly one writer (the +//! process that watches and re-indexes); the rest serve reads. + +mod project_root; +mod roots; + +use std::{ + collections::{HashMap, HashSet}, + fs::{File, OpenOptions, TryLockError}, + path::{Path, PathBuf}, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::Duration, +}; + +use notify_debouncer_full::{ + DebounceEventResult, Debouncer, RecommendedCache, new_debouncer, + notify::{RecommendedWatcher, RecursiveMode}, +}; +use oxcode_core::{GraphDirection, IndexStats, NodeKind, ProjectIndex}; +use project_root::{OptionalProjectRoot, resolve_project_root}; +use rmcp::{ + ErrorData as McpError, RoleServer, ServerHandler, ServiceExt, + handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + model::{CallToolResult, Content, ServerCapabilities, ServerInfo, TasksCapability}, + schemars, + service::NotificationContext, + task_handler, + task_manager::OperationProcessor, + tool, tool_handler, tool_router, + transport::stdio, +}; +use roots::RootsCache; +use serde::Deserialize; +use tokio::sync::{ + Mutex, + mpsc::{UnboundedReceiver, unbounded_channel}, +}; + +/// Default debounce window for the file watcher: collapse an editor's save burst +/// (write + rename of a temp file, etc.) into one re-index. Overridable with +/// `OXCODE_WATCH_DEBOUNCE_MS`. +const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(400); + +/// Default failover poll interval: how often a standby retries the writer lock so +/// it can take over when the current writer exits. Overridable with +/// `OXCODE_WATCH_POLL_MS`. +const DEFAULT_POLL: Duration = Duration::from_secs(3); + +/// Filename of the advisory single-writer lock, inside the `.oxcode` index dir. +const WATCH_LOCK_FILE: &str = "watch.lock"; + +/// Directory names whose filesystem events never warrant a re-index: the index +/// store itself (`.oxcode`, the load-bearing entry that prevents a write → +/// event → re-index feedback loop) plus the dirs source discovery already +/// skips. Mirrors `oxcode_core`'s scan skip list. +const WATCH_SKIP_DIRS: &[&str] = &[".oxcode", ".git", "target", "node_modules", "vendor"]; + +/// Runs the MCP server over stdio until the client disconnects. The index is not +/// touched until a client calls `oxcode_watch` (writer) or queries (reader). +pub(crate) fn serve() -> anyhow::Result<()> { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + runtime.block_on(async { + let service = OxcodeServer::new().serve(stdio()).await?; + service.waiting().await?; + Ok(()) + }) +} + +/// Server instructions steering agents to `oxcode_watch` then `oxcode_explore`. +const INSTRUCTIONS: &str = "This server answers questions about the code repository in the current \ +project. First call `oxcode_watch` (optional `path`): it builds the index if needed and keeps it \ +current as files change. When `path` is omitted, the project root is taken from OXCODE_ROOT, \ +CLAUDE_PROJECT_DIR, WORKSPACE_FOLDER_PATHS, or the client's MCP roots — not from this process's \ +cwd, which MCP hosts often set to $HOME. Pass `path` explicitly when in doubt. Only one MCP \ +instance watches a given folder at a time — a file lock elects a single writer; other instances \ +serve reads and take over automatically if the writer exits. Then, for almost any \ +code-understanding question, call `oxcode_explore` first with the user's question verbatim: it \ +returns the most relevant symbols (ranked by graph centrality), their source, the relationships \ +among them, the n-ary hyperedges they belong to (trait impl groups and container/module membership, \ +ranked by hypergraph PageRank — the architecture-altitude layer), the blast radius, and the call \ +flow — in one call. Use `oxcode_callers`/`oxcode_callees`/`oxcode_symbol` to follow specific edges, \ +and `oxcode_search`/`oxcode_files` only when explore did not surface the target. Prefer these query \ +tools over shelling out to grep or reading files. Every tool except `oxcode_watch` is read-only; do \ +not edit source files."; + +/// MCP server over oxcode's read-only queries plus the `oxcode_watch` file +/// watcher. Caches one opened index per root it writes, elects a single writer +/// per root via a file lock, and drives task-augmented calls through an +/// [`OperationProcessor`]. +#[derive(Clone)] +pub(crate) struct OxcodeServer { + #[expect( + dead_code, + reason = "stored per rmcp's #[tool_router] convention; the #[tool_handler]-generated request router reads it through macro-expanded code the dead-code pass does not attribute" + )] + tool_router: ToolRouter, + /// Opened readers cached per root this process writes (evicted on reindex). + indexes: Arc>>>, + /// Backs the rmcp `#[task_handler]` lifecycle for task-augmented tool calls. + operations: Arc>, + /// Roots this process is the elected writer for (holds the lock + watcher). + writers: Arc>>>, + /// Roots this process is a standby for (lost the lock; a failover task polls). + standbys: Arc>>, + /// Client MCP workspace roots (fetched outside tool handlers). + roots: RootsCache, + /// File-watcher debounce window. + debounce: Duration, + /// Failover poll interval for standbys. + poll: Duration, +} + +/// State for a root this process has been elected to write. Dropping it (on +/// process exit) releases the advisory lock and stops the watcher. +struct WriterState { + /// Held advisory `flock`; the kernel frees it on drop or process crash, so a + /// standby can take over. The file itself is never removed. + _lock_file: File, + /// Live debouncer; dropping it stops the watch thread. The `std::sync::Mutex` + /// makes `WriterState: Sync` regardless of the platform watcher's `Sync`-ness. + /// `None` when the watcher failed to start (the lock still elects this writer). + _watcher: std::sync::Mutex>>, + /// Number of reindexes this process has performed for the root (observability). + reindexes: Arc, +} + +/// A code question to answer in one curated call. +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub(crate) struct ExploreParams { + /// The task or question about the codebase, in natural language. + pub query: String, + #[serde(flatten)] + pub root: OptionalProjectRoot, + /// Maximum source characters to render (default 20000). + pub max_bytes: Option, +} + +/// A keyword search over indexed symbols. +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub(crate) struct SearchParams { + /// Keywords matched against symbol names, signatures, and docs. + pub query: String, + #[serde(flatten)] + pub root: OptionalProjectRoot, + /// Maximum number of matches (default 30). + pub limit: Option, + /// Restrict to these symbol kinds (e.g. function, method, struct, trait). + pub kinds: Option>, +} + +/// A call-graph query around one symbol selector. +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub(crate) struct CallParams { + /// Selector: a qualified name, `name:`, `element:`, or `file::`. + pub selector: String, + #[serde(flatten)] + pub root: OptionalProjectRoot, + /// Maximum hop depth (default 2). + pub depth: Option, + /// Maximum discovered symbol count (default 50). + pub limit: Option, +} + +/// One symbol selector to describe. +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub(crate) struct SymbolParams { + /// Selector: a qualified name, `name:`, `element:`, or `file::`. + pub selector: String, + #[serde(flatten)] + pub root: OptionalProjectRoot, +} + +/// A keyword search over indexed files. +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub(crate) struct FilesParams { + /// Keywords matched against file paths and their symbols. + pub query: String, + #[serde(flatten)] + pub root: OptionalProjectRoot, + /// Maximum number of files (default 30). + pub limit: Option, +} + +/// A project-root pointer. +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub(crate) struct StatusParams { + #[serde(flatten)] + pub root: OptionalProjectRoot, +} + +/// A project root to watch and keep indexed. +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub(crate) struct WatchParams { + #[serde(flatten)] + pub root: OptionalProjectRoot, +} + +#[tool_router] +impl OxcodeServer { + /// Builds a server with intervals from the environment (or defaults). Nothing + /// is indexed or watched until a client calls `oxcode_watch` or queries. + #[must_use] + pub(crate) fn new() -> Self { + Self::new_with( + env_duration("OXCODE_WATCH_DEBOUNCE_MS", DEFAULT_DEBOUNCE), + env_duration("OXCODE_WATCH_POLL_MS", DEFAULT_POLL), + ) + } + + /// Builds a server with explicit debounce + failover-poll windows (tests use + /// tiny values). + #[must_use] + fn new_with(debounce: Duration, poll: Duration) -> Self { + Self { + tool_router: Self::tool_router(), + indexes: Arc::new(Mutex::new(HashMap::new())), + operations: Arc::new(Mutex::new(OperationProcessor::new())), + writers: Arc::new(std::sync::Mutex::new(HashMap::new())), + standbys: Arc::new(std::sync::Mutex::new(HashSet::new())), + roots: RootsCache::new(), + debounce, + poll, + } + } + + #[tool( + description = "Start (or join) watching a project so its index is built and kept current as files change. Exactly one MCP instance per folder becomes the writer (it holds a file lock and re-indexes on changes); other instances become readers that just serve queries and automatically take over if the writer exits. Call this once before querying. Optional `path` defaults to the workspace (OXCODE_ROOT / CLAUDE_PROJECT_DIR / WORKSPACE_FOLDER_PATHS / MCP roots); never silently to $HOME.", + execution(task_support = "optional") + )] + async fn oxcode_watch( + &self, + Parameters(params): Parameters, + ) -> Result { + let root = self.resolve_root(params.root.path).await?; + + // Idempotent: already participating for this root. + if self.is_writer(&root) { + return json_result(&watch_body(&root, "writer", true, None)); + } + if self.is_standby(&root) { + return json_result(&watch_body(&root, "standby", false, None)); + } + + // The lock lives inside `.oxcode/`, which `.gitignore`s itself. + let index_directory = oxcode_core::index_dir(&root); + ensure_index_dir(&index_directory) + .map_err(|error| McpError::internal_error(error.to_string(), None))?; + let lock_file = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(index_directory.join(WATCH_LOCK_FILE)) + .map_err(|error| McpError::internal_error(format!("open watch lock: {error}"), None))?; + + match lock_file.try_lock() { + Ok(()) => { + let stats = self + .promote_to_writer(root.clone(), lock_file) + .await + .map_err(|error| McpError::internal_error(error.to_string(), None))?; + eprintln!("oxcode: elected as writer for {}", root.display()); + json_result(&watch_body(&root, "writer", true, Some(&stats))) + } + Err(TryLockError::WouldBlock) => { + self.standbys + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(root.clone()); + tokio::spawn(self.clone().failover_loop(root.clone(), lock_file)); + eprintln!( + "oxcode: standby — another instance is watching {}", + root.display() + ); + json_result(&watch_body(&root, "standby", false, None)) + } + Err(TryLockError::Error(error)) => Err(McpError::internal_error( + format!("acquire watch lock: {error}"), + None, + )), + } + } + + #[tool( + description = "Answer a code question in one call: returns the most relevant symbols ranked by graph centrality, their source, relationships, n-ary hyperedges (trait impl groups and container membership, ranked by hypergraph PageRank for architecture-altitude questions), blast radius, and call flow for the query. Use this first for any code-understanding question.", + execution(task_support = "optional") + )] + async fn oxcode_explore( + &self, + Parameters(params): Parameters, + ) -> Result { + let index = self.index_for(params.root.path).await?; + let query = params.query; + let max_bytes = params.max_bytes.unwrap_or(20_000); + let report = blocking(move || index.context(&query, 8, 1, max_bytes)).await?; + json_result(&report) + } + + #[tool( + description = "Search indexed symbols by keyword, optionally restricted to symbol kinds." + )] + async fn oxcode_search( + &self, + Parameters(params): Parameters, + ) -> Result { + let index = self.index_for(params.root.path).await?; + let query = params.query; + let limit = params.limit.unwrap_or(30); + let kinds = parse_kinds(params.kinds.as_deref()); + let report = blocking(move || index.search_symbols_filtered(&query, limit, &kinds)).await?; + json_result(&report) + } + + #[tool(description = "Find the functions that call the given symbol (incoming call graph).")] + async fn oxcode_callers( + &self, + Parameters(params): Parameters, + ) -> Result { + self.call_graph(params, GraphDirection::Incoming).await + } + + #[tool(description = "Find the functions called by the given symbol (outgoing call graph).")] + async fn oxcode_callees( + &self, + Parameters(params): Parameters, + ) -> Result { + self.call_graph(params, GraphDirection::Outgoing).await + } + + #[tool( + description = "Describe one symbol by selector (qualified name, name:, element:, or file::)." + )] + async fn oxcode_symbol( + &self, + Parameters(params): Parameters, + ) -> Result { + let index = self.index_for(params.root.path).await?; + let selector = params.selector; + let value = blocking(move || resolve_symbol(&index, &selector)).await?; + json_result(&value) + } + + #[tool(description = "Search indexed files by keyword.")] + async fn oxcode_files( + &self, + Parameters(params): Parameters, + ) -> Result { + let index = self.index_for(params.root.path).await?; + let query = params.query; + let limit = params.limit.unwrap_or(30); + let report = blocking(move || index.search_files(&query, limit)).await?; + json_result(&report) + } + + #[tool( + description = "Show the project's database status (element/relation counts, paths) plus this instance's watch role (writer/standby/reader) and how many times it has re-indexed." + )] + async fn oxcode_status( + &self, + Parameters(params): Parameters, + ) -> Result { + let root = self.resolve_root(params.root.path).await?; + let (role, watching, reindexes) = self.watch_state(&root); + let status_root = root.clone(); + let database = blocking(move || oxcode_core::project_status(&status_root)).await?; + let body = serde_json::json!({ + "watch": { "role": role, "watching": watching, "reindexes": reindexes }, + "database": database, + }); + json_result(&body) + } + + /// Shared call-graph path for callers/callees. + async fn call_graph( + &self, + params: CallParams, + direction: GraphDirection, + ) -> Result { + let index = self.index_for(params.root.path).await?; + let selector = params.selector; + let depth = params.depth.unwrap_or(2); + let limit = params.limit.unwrap_or(50); + let report = blocking(move || index.call_graph(&selector, direction, depth, limit)).await?; + json_result(&report) + } + + /// Opens the index for `path` (default: workspace root). If this process is the + /// writer for the root, the opened reader is cached and evicted on each reindex; + /// any other process opens fresh per query so it reflects the writer's latest + /// commit. A missing index is not built here — call `oxcode_watch` first. + async fn index_for(&self, path: Option) -> Result, McpError> { + let root = self.resolve_root(path).await?; + if self.is_writer(&root) { + if let Some(index) = self.indexes.lock().await.get(&root) { + return Ok(Arc::clone(index)); + } + let open_root = root.clone(); + let index = Arc::new(blocking(move || ProjectIndex::open(&open_root)).await?); + self.indexes.lock().await.insert(root, Arc::clone(&index)); + return Ok(index); + } + if !oxcode_core::database_dir(&root).exists() { + return Err(McpError::invalid_params( + format!( + "no index yet for {} — call oxcode_watch to build and keep it current", + root.display() + ), + None, + )); + } + // Reader: open fresh so the writer's latest committed snapshot is visible. + let open_root = root.clone(); + Ok(Arc::new( + blocking(move || ProjectIndex::open(&open_root)).await?, + )) + } + + /// Builds/refreshes `root`, starts its watcher, and records this process as the + /// writer. Caller must already hold the advisory lock (`lock_file`). + async fn promote_to_writer( + &self, + root: PathBuf, + lock_file: File, + ) -> anyhow::Result { + let write_lock = Arc::new(Mutex::new(())); + let reindexes = Arc::new(AtomicU64::new(0)); + let stats = run_reindex(&self.indexes, &root, &write_lock, &reindexes).await?; + let watcher = self.spawn_watch(&root, write_lock, Arc::clone(&reindexes)); + let state = Arc::new(WriterState { + _lock_file: lock_file, + _watcher: std::sync::Mutex::new(watcher), + reindexes, + }); + self.writers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(root.clone(), state); + self.standbys + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&root); + Ok(stats) + } + + /// Failover: poll the writer lock; when the current writer exits and frees it, + /// promote this process to writer (build + watch). Runs until promotion. + async fn failover_loop(self, root: PathBuf, lock_file: File) { + loop { + tokio::time::sleep(self.poll).await; + if self.is_writer(&root) { + break; + } + match lock_file.try_lock() { + Ok(()) => { + self.take_over(root, lock_file).await; + break; + } + Err(TryLockError::WouldBlock) => continue, + Err(TryLockError::Error(error)) => { + eprintln!( + "oxcode: failover lock error for {}: {error}", + root.display() + ); + break; + } + } + } + } + + /// Promotes this process to writer for `root` after winning the freed lock, + /// logging the outcome to stderr. + async fn take_over(&self, root: PathBuf, lock_file: File) { + match self.promote_to_writer(root.clone(), lock_file).await { + Ok(_) => eprintln!( + "oxcode: promoted to writer after previous writer released {}", + root.display() + ), + Err(error) => { + eprintln!( + "oxcode: failover index failed for {}: {error}", + root.display() + ) + } + } + } + + /// Starts a recursive debounced watcher on `root` and a task that re-indexes + /// (serialized by `write_lock`) on each debounced change. Returns `None` if the + /// watcher could not be started. + fn spawn_watch( + &self, + root: &Path, + write_lock: Arc>, + reindexes: Arc, + ) -> Option> { + let (tick_tx, tick_rx) = unbounded_channel::<()>(); + let mut debouncer = + match new_debouncer(self.debounce, None, move |result: DebounceEventResult| { + // Tick on any batch that touches at least one indexable path. A + // batch confined to skip dirs (notably `.oxcode/`, which our own + // re-index writes) is dropped — this is what breaks the feedback + // loop. Watcher errors are transient; the next real event re-syncs. + if let Ok(events) = result + && events + .iter() + .flat_map(|event| event.paths.iter()) + .any(|path| !is_ignored_path(path)) + { + let _ = tick_tx.send(()); + } + }) { + Ok(debouncer) => debouncer, + Err(error) => { + eprintln!( + "oxcode: file watcher unavailable for {}: {error}", + root.display() + ); + return None; + } + }; + if let Err(error) = debouncer.watch(root, RecursiveMode::Recursive) { + eprintln!("oxcode: cannot watch {}: {error}", root.display()); + return None; + } + tokio::spawn(watch_loop( + Arc::clone(&self.indexes), + root.to_path_buf(), + write_lock, + reindexes, + tick_rx, + )); + Some(debouncer) + } + + /// Whether this process is the elected writer for `root`. + fn is_writer(&self, root: &Path) -> bool { + self.writers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains_key(root) + } + + /// Whether this process is a standby (failover participant) for `root`. + fn is_standby(&self, root: &Path) -> bool { + self.standbys + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(root) + } + + /// This process's role for `root`, plus whether it is watching and its reindex + /// count (0 for non-writers). + fn watch_state(&self, root: &Path) -> (&'static str, bool, u64) { + if let Some(state) = self + .writers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(root) + { + return ("writer", true, state.reindexes.load(Ordering::Relaxed)); + } + if self.is_standby(root) { + return ("standby", false, 0); + } + ("reader", false, 0) + } + + /// Resolves the project root from an optional `path`, preferring workspace + /// signals over this process's cwd. MCP hosts often start servers with + /// `cwd=$HOME` even when the agent is in a project folder; omitting `path` + /// must not silently index the home directory. + /// + /// Never calls `roots/list` here — nested client requests during tool handling + /// can hang on some hosts. Roots are fetched from `on_initialized` / + /// `on_roots_list_changed` only; this waits briefly for that in-flight fetch + /// when the cache is still cold. + async fn resolve_root(&self, path: Option) -> Result { + // Explicit path / host env win without waiting on MCP roots. + if path + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + || project_root::env_project_root().is_some() + { + return resolve_project_root(path, None); + } + let mcp_root = self.roots.wait_ready().await; + resolve_project_root(path, mcp_root) + } +} + +/// Re-indexes `root` on each debounced change tick until the watcher stops. +async fn watch_loop( + indexes: Arc>>>, + root: PathBuf, + write_lock: Arc>, + reindexes: Arc, + mut tick_rx: UnboundedReceiver<()>, +) { + while tick_rx.recv().await.is_some() { + // Collapse a burst of ticks that landed during the last re-index into one run. + while tick_rx.try_recv().is_ok() {} + match run_reindex(&indexes, &root, &write_lock, &reindexes).await { + Ok(_) => eprintln!( + "oxcode: re-indexed {} (#{})", + root.display(), + reindexes.load(Ordering::Relaxed) + ), + Err(error) => eprintln!("oxcode: re-index failed for {}: {error}", root.display()), + } + } +} + +/// Runs `index_project` for `root` under `write_lock` (serializing this process's +/// writers), evicts the cached reader so the next query reopens the fresh index, +/// and bumps the reindex counter. An unchanged tree is a cheap digest no-op. +async fn run_reindex( + indexes: &Arc>>>, + root: &Path, + write_lock: &Mutex<()>, + reindexes: &AtomicU64, +) -> anyhow::Result { + let _guard = write_lock.lock().await; + let root_owned = root.to_path_buf(); + let stats = + tokio::task::spawn_blocking(move || oxcode_core::index_project(&root_owned)).await??; + // Bump the counter before evicting the cache: the eviction is what lets a + // concurrent reader observe the new commit, so ordering the increment first + // guarantees "new symbol visible" implies "reindex counted". + reindexes.fetch_add(1, Ordering::Relaxed); + indexes.lock().await.remove(root); + Ok(stats) +} + +/// Whether a changed path falls in a directory source discovery skips, so its +/// events should not trigger a re-index. Mirrors `oxcode_core`'s scan skip list; +/// `.oxcode/` is the load-bearing entry that prevents a self-triggered loop. +fn is_ignored_path(path: &Path) -> bool { + path.components().any(|component| { + matches!(component, std::path::Component::Normal(name) + if WATCH_SKIP_DIRS.iter().any(|skip| name == std::ffi::OsStr::new(skip))) + }) +} + +/// Creates the `.oxcode` index dir and its self-ignoring `.gitignore` so the lock +/// file is never committed. Idempotent. +fn ensure_index_dir(index_directory: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(index_directory)?; + let gitignore = index_directory.join(".gitignore"); + if !gitignore.exists() { + std::fs::write(&gitignore, "*\n")?; + } + Ok(()) +} + +/// Reads a millisecond duration from `key`, falling back to `default`. +fn env_duration(key: &str, default: Duration) -> Duration { + std::env::var(key) + .ok() + .and_then(|value| value.parse::().ok()) + .map(Duration::from_millis) + .unwrap_or(default) +} + +/// Builds the JSON body for an `oxcode_watch` response. +fn watch_body( + root: &Path, + role: &str, + watching: bool, + stats: Option<&IndexStats>, +) -> serde_json::Value { + let mut body = serde_json::json!({ + "root": root.display().to_string(), + "role": role, + "watching": watching, + }); + if let Some(stats) = stats { + body["index"] = serde_json::to_value(stats).unwrap_or(serde_json::Value::Null); + } else if !watching { + body["message"] = serde_json::json!( + "another oxcode instance is watching this root; standing by to take over if it exits" + ); + } + body +} + +#[tool_handler] +#[task_handler(processor = self.operations)] +impl ServerHandler for OxcodeServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_tasks_with(TasksCapability::server_default()) + .build(), + ) + .with_instructions(INSTRUCTIONS) + } + + async fn on_initialized(&self, context: NotificationContext) { + self.roots.fetch(&context.peer).await; + } + + async fn on_roots_list_changed(&self, context: NotificationContext) { + self.roots.fetch(&context.peer).await; + } +} + +/// Parses caller-supplied kind strings into `NodeKind`, dropping unknown ones. +fn parse_kinds(kinds: Option<&[String]>) -> Vec { + kinds + .unwrap_or_default() + .iter() + .filter_map(|kind| NodeKind::try_from(kind.as_str()).ok()) + .collect() +} + +/// Resolves a selector to a single symbol, or a structured ambiguous/not-found value. +fn resolve_symbol(index: &ProjectIndex, selector: &str) -> oxcode_core::Result { + let value = match index.resolve_selector(selector)?.as_slice() { + [single] => serde_json::json!({ "status": "matched", "symbol": single }), + [] => serde_json::json!({ "status": "not_found", "selector": selector, "matches": [] }), + matches => { + serde_json::json!({ "status": "ambiguous", "selector": selector, "matches": matches }) + } + }; + Ok(value) +} + +/// Runs a blocking oxcode read on the blocking pool, mapping errors to MCP errors. +async fn blocking(f: F) -> Result +where + T: Send + 'static, + F: FnOnce() -> oxcode_core::Result + Send + 'static, +{ + tokio::task::spawn_blocking(f) + .await + .map_err(|error| McpError::internal_error(format!("oxcode task failed: {error}"), None))? + .map_err(|error| McpError::internal_error(error.to_string(), None)) +} + +/// Serializes a report into one JSON text content block. +fn json_result(value: &T) -> Result { + let text = serde_json::to_string(value) + .map_err(|error| McpError::internal_error(format!("serialize failed: {error}"), None))?; + Ok(CallToolResult::success(vec![Content::text(text)])) +} + +#[cfg(test)] +#[path = "integration_tests.rs"] +mod tests; diff --git a/crates/oxcode-cli/src/mcp/project_root.rs b/crates/oxcode-cli/src/mcp/project_root.rs new file mode 100644 index 0000000..9760918 --- /dev/null +++ b/crates/oxcode-cli/src/mcp/project_root.rs @@ -0,0 +1,232 @@ +//! Resolve the project root for omitted MCP `path` arguments. +//! +//! MCP hosts often start the server process with `cwd=$HOME` even when the +//! agent is in a project folder. Prefer host-injected env vars and parsed +//! `file://` MCP roots over process cwd, and refuse `$HOME` unless `path` was +//! explicit. + +use std::path::{Path, PathBuf}; + +use rmcp::{ErrorData as McpError, schemars}; +use serde::Deserialize; + +/// Shared optional `path` field for every tool that accepts a project root. +/// +/// Flattened into each tool's params struct so the wire shape stays `path?` +/// while the defaulting docs live in one place. +#[derive(Debug, Default, Deserialize, schemars::JsonSchema)] +pub(crate) struct OptionalProjectRoot { + /// Project root; defaults to the workspace (`OXCODE_ROOT` / + /// `CLAUDE_PROJECT_DIR` / `WORKSPACE_FOLDER_PATHS` / MCP roots), never + /// silently to `$HOME`. + pub path: Option, +} + +/// Resolves an optional tool `path` against env defaults and a ready MCP root. +/// +/// `mcp_root` is the first client workspace root already fetched outside the +/// tool handler (see [`super::roots::RootsCache`]); this never calls +/// `roots/list`. +pub(crate) fn resolve_project_root( + path: Option, + mcp_root: Option, +) -> Result { + let explicit = path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(PathBuf::from); + let raw = if let Some(explicit_path) = explicit.clone() { + explicit_path + } else if let Some(from_env) = env_project_root() { + from_env + } else if let Some(from_roots) = mcp_root { + from_roots + } else { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) + }; + let root = canonicalize_root(raw); + if explicit.is_none() && is_home_directory(&root) { + return Err(McpError::invalid_params( + format!( + "refusing to use home directory {} as the project root — MCP hosts often \ + start this server with cwd=$HOME even when your workspace is elsewhere. \ + Pass `path` (the project folder), or set OXCODE_ROOT / CLAUDE_PROJECT_DIR \ + / WORKSPACE_FOLDER_PATHS.", + root.display() + ), + None, + )); + } + Ok(root) +} + +/// Project root from host-injected environment variables, in priority order. +/// +/// MCP hosts frequently leave the server process cwd at `$HOME` while advertising +/// the real workspace via these variables (Claude Code → `CLAUDE_PROJECT_DIR`, +/// Cursor → `WORKSPACE_FOLDER_PATHS`). `OXCODE_ROOT` is the explicit override. +pub(crate) fn env_project_root() -> Option { + for key in ["OXCODE_ROOT", "CLAUDE_PROJECT_DIR"] { + if let Ok(value) = std::env::var(key) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Some(PathBuf::from(trimmed)); + } + } + } + std::env::var("WORKSPACE_FOLDER_PATHS") + .ok() + .and_then(|value| first_workspace_folder(&value)) +} + +/// First folder from a `WORKSPACE_FOLDER_PATHS` value (single path or CSV). +pub(crate) fn first_workspace_folder(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + let as_path = PathBuf::from(trimmed); + if as_path.is_dir() { + return Some(as_path); + } + // Multi-root workspaces: comma-separated. Do not split on `:` — that breaks + // Windows drive letters (`C:\...`). + trimmed + .split(',') + .map(str::trim) + .find(|part| !part.is_empty()) + .map(PathBuf::from) +} + +/// Whether `path` is the current user's home directory (best-effort). +pub(crate) fn is_home_directory(path: &Path) -> bool { + let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) else { + return false; + }; + let home = PathBuf::from(home); + canonicalize_root(path.to_path_buf()) == canonicalize_root(home) +} + +/// Canonicalizes best-effort so reader cache / writer registry / lock file key on +/// the same absolute path (FS events report canonical paths). Falls back to the +/// raw path when it does not exist yet. +pub(crate) fn canonicalize_root(path: PathBuf) -> PathBuf { + std::fs::canonicalize(&path).unwrap_or(path) +} + +/// Converts a `file://` MCP root URI into a filesystem path. +pub(crate) fn file_uri_to_path(uri: &str) -> Option { + let rest = uri.strip_prefix("file://")?; + let path = if let Some(path) = rest.strip_prefix("localhost") { + path + } else if rest.starts_with('/') { + rest + } else { + return None; + }; + let decoded = percent_decode(path); + if decoded.is_empty() { + return None; + } + // `file:///C:/Users/...` percent-decodes to `/C:/Users/...`. On Windows the + // usable path is `C:/Users/...` — same as `Url::to_file_path`. + let normalized = match decoded.as_bytes() { + [b'/', drive, b':', ..] if drive.is_ascii_alphabetic() => decoded[1..].to_owned(), + _ => decoded, + }; + Some(PathBuf::from(normalized)) +} + +/// Decodes `%XX` sequences in a URI path; returns the input unchanged when none. +fn percent_decode(input: &str) -> String { + if !input.contains('%') { + return input.to_owned(); + } + let bytes = input.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' + && index + 2 < bytes.len() + && let (Some(high), Some(low)) = + (hex_nibble(bytes[index + 1]), hex_nibble(bytes[index + 2])) + { + out.push((high << 4) | low); + index += 3; + continue; + } + out.push(bytes[index]); + index += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +fn hex_nibble(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn first_workspace_folder_accepts_csv_and_single_path() { + let project = tempfile::TempDir::new().expect("temp"); + let path = project.path().to_string_lossy().into_owned(); + assert_eq!( + first_workspace_folder(&path), + Some(PathBuf::from(&path)), + "existing single path wins without splitting" + ); + assert_eq!( + first_workspace_folder(&format!("{path},/does/not/exist")), + Some(PathBuf::from(&path)) + ); + assert_eq!( + first_workspace_folder("/missing/a,/missing/b"), + Some(PathBuf::from("/missing/a")) + ); + } + + #[test] + fn file_uri_to_path_decodes_file_roots() { + assert_eq!( + file_uri_to_path("file:///Users/snowmead/opt/jinttai"), + Some(PathBuf::from("/Users/snowmead/opt/jinttai")) + ); + assert_eq!( + file_uri_to_path("file://localhost/tmp/project%20name"), + Some(PathBuf::from("/tmp/project name")) + ); + assert_eq!( + file_uri_to_path("file:///C:/Users/snowmead/opt/jinttai"), + Some(PathBuf::from("C:/Users/snowmead/opt/jinttai")) + ); + assert_eq!(file_uri_to_path("https://example.com"), None); + } + + #[test] + fn is_home_directory_matches_home_env() { + let home = tempfile::TempDir::new().expect("home"); + let previous_home = std::env::var_os("HOME"); + // SAFETY: test-local HOME mutation; restored before return. + unsafe { + std::env::set_var("HOME", home.path()); + } + assert!(is_home_directory(home.path())); + assert!(!is_home_directory(&home.path().join("opt/jinttai"))); + // SAFETY: restore prior HOME. + unsafe { + match previous_home { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + } + } +} diff --git a/crates/oxcode-cli/src/mcp/roots.rs b/crates/oxcode-cli/src/mcp/roots.rs new file mode 100644 index 0000000..b0b134b --- /dev/null +++ b/crates/oxcode-cli/src/mcp/roots.rs @@ -0,0 +1,147 @@ +//! Client MCP workspace roots, fetched outside tool handlers. +//! +//! Nested `roots/list` during a tool call can hang some hosts. This cache is +//! populated from `on_initialized` / `on_roots_list_changed` only; tool +//! handlers wait for a `Ready` publication. + +use std::{path::PathBuf, sync::Arc, time::Duration}; + +use rmcp::{Peer, RoleServer, model::Root}; +use tokio::sync::watch; + +use super::project_root::file_uri_to_path; + +/// How long an omitted-`path` resolve will wait for a roots fetch before +/// falling through without using a possibly-stale cache. +const ROOTS_READY_WAIT: Duration = Duration::from_millis(500); + +/// Published state of the client's MCP workspace roots. +#[derive(Debug, Clone, PartialEq, Eq)] +enum RootsState { + /// A fetch has not completed yet, or a refresh is in progress. + Pending, + /// Last completed fetch. `None` means the client has no usable root. + Ready(Option), +} + +/// Outcome of one `roots/list` attempt. +enum LoadOutcome { + /// Client advertised no roots capability, or the RPC failed. + Unavailable, + /// Client returned an empty roots array. + Empty, + /// Client returned URIs but none parsed to a filesystem path. + Unparseable, + /// First usable filesystem path. + Parsed(PathBuf), +} + +/// Single watch-backed cache for MCP roots. +#[derive(Clone)] +pub(crate) struct RootsCache { + tx: Arc>, + rx: watch::Receiver, +} + +impl RootsCache { + #[must_use] + pub(crate) fn new() -> Self { + let (tx, rx) = watch::channel(RootsState::Pending); + Self { + tx: Arc::new(tx), + rx, + } + } + + /// Waits briefly for a completed fetch and returns the ready root, if any. + /// + /// Never issues `roots/list`. On timeout while still `Pending` (including an + /// in-flight refresh), returns `None` so the caller does not keep using a + /// stale workspace after a folder switch. + pub(crate) async fn wait_ready(&self) -> Option { + if let RootsState::Ready(root) = &*self.rx.borrow() { + return root.clone(); + } + let mut rx = self.rx.clone(); + let finished = tokio::time::timeout( + ROOTS_READY_WAIT, + rx.wait_for(|state| matches!(state, RootsState::Ready(_))), + ) + .await; + match finished { + Ok(Ok(state)) => match &*state { + RootsState::Ready(root) => root.clone(), + RootsState::Pending => None, + }, + _ => None, + } + } + + /// Fetches `roots/list` and publishes [`RootsState::Ready`]. + /// + /// Marks [`RootsState::Pending`] first so concurrent waiters observe the + /// refresh. RPC failures and unparseable URI lists restore the previous + /// ready root; an explicitly empty list clears it. + pub(crate) async fn fetch(&self, peer: &Peer) { + let previous = match &*self.rx.borrow() { + RootsState::Ready(root) => root.clone(), + RootsState::Pending => None, + }; + let _ = self.tx.send(RootsState::Pending); + let published = match self.load_root(peer).await { + LoadOutcome::Empty => None, + LoadOutcome::Parsed(path) => Some(path), + LoadOutcome::Unavailable | LoadOutcome::Unparseable => previous, + }; + let _ = self.tx.send(RootsState::Ready(published)); + } + + async fn load_root(&self, peer: &Peer) -> LoadOutcome { + if peer + .peer_info() + .and_then(|info| info.capabilities.roots.as_ref()) + .is_none() + { + return LoadOutcome::Unavailable; + } + let Ok(result) = peer.list_roots().await else { + return LoadOutcome::Unavailable; + }; + classify_roots_list(result.roots) + } +} + +/// Classifies a `roots/list` payload. +fn classify_roots_list(roots: Vec) -> LoadOutcome { + if roots.is_empty() { + return LoadOutcome::Empty; + } + match roots + .iter() + .filter_map(|root| file_uri_to_path(&root.uri)) + .next() + { + Some(path) => LoadOutcome::Parsed(std::fs::canonicalize(&path).unwrap_or(path)), + None => LoadOutcome::Unparseable, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_roots_list_empty_unparseable_and_parsed() { + assert!(matches!(classify_roots_list(vec![]), LoadOutcome::Empty)); + assert!(matches!( + classify_roots_list(vec![Root::new("https://example.com")]), + LoadOutcome::Unparseable + )); + let project = tempfile::TempDir::new().expect("temp"); + let uri = format!("file://{}", project.path().display()); + assert!(matches!( + classify_roots_list(vec![Root::new(uri)]), + LoadOutcome::Parsed(path) if path == project.path() + )); + } +} From 1cc3485b9ab41ad1f7e45723df395232ca966aa5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 04:18:43 +0000 Subject: [PATCH 08/11] fix(mcp): prefer live MCP roots over sticky host env CLAUDE_PROJECT_DIR / WORKSPACE_FOLDER_PATHS are process-start snapshots and must not outrank a refreshed roots cache after a folder switch. Keep OXCODE_ROOT as an explicit pin above roots. Co-authored-by: Michael Assaf --- README.md | 4 +- .../oxcode-cli/src/mcp/integration_tests.rs | 10 +- crates/oxcode-cli/src/mcp/mod.rs | 11 +- crates/oxcode-cli/src/mcp/project_root.rs | 119 +++++++++++++++--- prompts/arms/oxcode-mcp.md | 2 +- 5 files changed, 115 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index aa77e77..6cd680b 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,8 @@ Add the server to your agent. For Claude Code (`~/.claude.json`): Once wired, have the agent call `oxcode_watch` once: it builds the index and keeps it current as files change. When `path` is omitted, the project root comes -from `OXCODE_ROOT`, `CLAUDE_PROJECT_DIR`, `WORKSPACE_FOLDER_PATHS`, or the -client's MCP roots — not from the MCP process cwd (hosts often start servers in +from `OXCODE_ROOT`, the client's MCP roots, `CLAUDE_PROJECT_DIR`, or +`WORKSPACE_FOLDER_PATHS` — not from the MCP process cwd (hosts often start servers in `$HOME`). Across multiple agents on one repo a file lock elects a single writer (the one watcher/re-indexer) while the rest serve reads, so you can run as many as you like. Then ask questions with `oxcode_explore`. diff --git a/crates/oxcode-cli/src/mcp/integration_tests.rs b/crates/oxcode-cli/src/mcp/integration_tests.rs index c7fade1..449275d 100644 --- a/crates/oxcode-cli/src/mcp/integration_tests.rs +++ b/crates/oxcode-cli/src/mcp/integration_tests.rs @@ -22,14 +22,14 @@ use rmcp::{ }; use super::{ - project_root::{canonicalize_root, env_project_root}, + project_root::{canonicalize_root, oxcode_root_override}, *, }; /// Serializes tests that mutate process-global project-root env vars. static PROJECT_ROOT_ENV_LOCK: Mutex<()> = Mutex::new(()); -/// Env keys consulted by [`env_project_root`], for save/restore around tests. +/// Env keys consulted for omitted-`path` defaults, for save/restore around tests. const PROJECT_ROOT_ENV_KEYS: &[&str] = &[ "OXCODE_ROOT", "CLAUDE_PROJECT_DIR", @@ -235,11 +235,13 @@ async fn poll_until_terminal( } #[test] -fn env_project_root_prefers_oxcode_root() { +fn oxcode_root_override_wins_over_host_env() { let project = tempfile::TempDir::new().expect("temp"); + let host = tempfile::TempDir::new().expect("host"); let guard = ProjectRootEnvGuard::clear(); + guard.set("CLAUDE_PROJECT_DIR", host.path()); guard.set("OXCODE_ROOT", project.path()); - let resolved = env_project_root().expect("OXCODE_ROOT"); + let resolved = oxcode_root_override().expect("OXCODE_ROOT"); assert_eq!( canonicalize_root(resolved), canonicalize_root(project.path().to_path_buf()) diff --git a/crates/oxcode-cli/src/mcp/mod.rs b/crates/oxcode-cli/src/mcp/mod.rs index cfec448..b7d0126 100644 --- a/crates/oxcode-cli/src/mcp/mod.rs +++ b/crates/oxcode-cli/src/mcp/mod.rs @@ -80,7 +80,7 @@ pub(crate) fn serve() -> anyhow::Result<()> { const INSTRUCTIONS: &str = "This server answers questions about the code repository in the current \ project. First call `oxcode_watch` (optional `path`): it builds the index if needed and keeps it \ current as files change. When `path` is omitted, the project root is taken from OXCODE_ROOT, \ -CLAUDE_PROJECT_DIR, WORKSPACE_FOLDER_PATHS, or the client's MCP roots — not from this process's \ +the client's MCP roots, CLAUDE_PROJECT_DIR, or WORKSPACE_FOLDER_PATHS — not from this process's \ cwd, which MCP hosts often set to $HOME. Pass `path` explicitly when in doubt. Only one MCP \ instance watches a given folder at a time — a file lock elects a single writer; other instances \ serve reads and take over automatically if the writer exits. Then, for almost any \ @@ -234,7 +234,7 @@ impl OxcodeServer { } #[tool( - description = "Start (or join) watching a project so its index is built and kept current as files change. Exactly one MCP instance per folder becomes the writer (it holds a file lock and re-indexes on changes); other instances become readers that just serve queries and automatically take over if the writer exits. Call this once before querying. Optional `path` defaults to the workspace (OXCODE_ROOT / CLAUDE_PROJECT_DIR / WORKSPACE_FOLDER_PATHS / MCP roots); never silently to $HOME.", + description = "Start (or join) watching a project so its index is built and kept current as files change. Exactly one MCP instance per folder becomes the writer (it holds a file lock and re-indexes on changes); other instances become readers that just serve queries and automatically take over if the writer exits. Call this once before querying. Optional `path` defaults to the workspace (OXCODE_ROOT / MCP roots / CLAUDE_PROJECT_DIR / WORKSPACE_FOLDER_PATHS); never silently to $HOME.", execution(task_support = "optional") )] async fn oxcode_watch( @@ -585,12 +585,15 @@ impl OxcodeServer { /// `on_roots_list_changed` only; this waits briefly for that in-flight fetch /// when the cache is still cold. async fn resolve_root(&self, path: Option) -> Result { - // Explicit path / host env win without waiting on MCP roots. + // Explicit path / OXCODE_ROOT pin win without waiting on MCP roots. + // Host env snapshots (CLAUDE_PROJECT_DIR / WORKSPACE_FOLDER_PATHS) do + // not — they are fixed at process start and must lose to a refreshed + // roots cache after a folder switch. if path .as_deref() .map(str::trim) .is_some_and(|value| !value.is_empty()) - || project_root::env_project_root().is_some() + || project_root::oxcode_root_override().is_some() { return resolve_project_root(path, None); } diff --git a/crates/oxcode-cli/src/mcp/project_root.rs b/crates/oxcode-cli/src/mcp/project_root.rs index 9760918..2bf8c4b 100644 --- a/crates/oxcode-cli/src/mcp/project_root.rs +++ b/crates/oxcode-cli/src/mcp/project_root.rs @@ -1,9 +1,9 @@ //! Resolve the project root for omitted MCP `path` arguments. //! //! MCP hosts often start the server process with `cwd=$HOME` even when the -//! agent is in a project folder. Prefer host-injected env vars and parsed -//! `file://` MCP roots over process cwd, and refuse `$HOME` unless `path` was -//! explicit. +//! agent is in a project folder. Prefer a live MCP roots cache (and then +//! host-injected env snapshots) over process cwd, and refuse `$HOME` unless +//! `path` was explicit. use std::path::{Path, PathBuf}; @@ -16,14 +16,20 @@ use serde::Deserialize; /// while the defaulting docs live in one place. #[derive(Debug, Default, Deserialize, schemars::JsonSchema)] pub(crate) struct OptionalProjectRoot { - /// Project root; defaults to the workspace (`OXCODE_ROOT` / - /// `CLAUDE_PROJECT_DIR` / `WORKSPACE_FOLDER_PATHS` / MCP roots), never - /// silently to `$HOME`. + /// Project root; defaults to the workspace (`OXCODE_ROOT` / MCP roots / + /// `CLAUDE_PROJECT_DIR` / `WORKSPACE_FOLDER_PATHS`), never silently to + /// `$HOME`. pub path: Option, } /// Resolves an optional tool `path` against env defaults and a ready MCP root. /// +/// Order for omitted `path`: `OXCODE_ROOT` (explicit pin) → `mcp_root` (live +/// client roots) → host env snapshots (`CLAUDE_PROJECT_DIR` / +/// `WORKSPACE_FOLDER_PATHS`) → process cwd. Host env sits below MCP roots +/// because those vars are fixed at process start while roots refresh on +/// `roots/list_changed`. +/// /// `mcp_root` is the first client workspace root already fetched outside the /// tool handler (see [`super::roots::RootsCache`]); this never calls /// `roots/list`. @@ -38,10 +44,12 @@ pub(crate) fn resolve_project_root( .map(PathBuf::from); let raw = if let Some(explicit_path) = explicit.clone() { explicit_path - } else if let Some(from_env) = env_project_root() { - from_env + } else if let Some(pin) = oxcode_root_override() { + pin } else if let Some(from_roots) = mcp_root { from_roots + } else if let Some(from_host) = host_env_project_root() { + from_host } else { std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) }; @@ -61,25 +69,36 @@ pub(crate) fn resolve_project_root( Ok(root) } -/// Project root from host-injected environment variables, in priority order. +/// Explicit user/config pin. Wins over live MCP roots and host env snapshots. +pub(crate) fn oxcode_root_override() -> Option { + non_empty_env("OXCODE_ROOT") +} + +/// Host-injected workspace snapshots (Claude Code / Cursor). /// -/// MCP hosts frequently leave the server process cwd at `$HOME` while advertising -/// the real workspace via these variables (Claude Code → `CLAUDE_PROJECT_DIR`, -/// Cursor → `WORKSPACE_FOLDER_PATHS`). `OXCODE_ROOT` is the explicit override. -pub(crate) fn env_project_root() -> Option { - for key in ["OXCODE_ROOT", "CLAUDE_PROJECT_DIR"] { - if let Ok(value) = std::env::var(key) { - let trimmed = value.trim(); - if !trimmed.is_empty() { - return Some(PathBuf::from(trimmed)); - } - } +/// These are set at process start and typically do not update when the client +/// switches folders via `roots/list_changed`, so callers should prefer a ready +/// MCP root when one is available. +pub(crate) fn host_env_project_root() -> Option { + if let Some(path) = non_empty_env("CLAUDE_PROJECT_DIR") { + return Some(path); } std::env::var("WORKSPACE_FOLDER_PATHS") .ok() .and_then(|value| first_workspace_folder(&value)) } +fn non_empty_env(key: &str) -> Option { + std::env::var(key).ok().and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(PathBuf::from(trimmed)) + } + }) +} + /// First folder from a `WORKSPACE_FOLDER_PATHS` value (single path or CSV). pub(crate) fn first_workspace_folder(value: &str) -> Option { let trimmed = value.trim(); @@ -171,6 +190,17 @@ fn hex_nibble(byte: u8) -> Option { } } +#[cfg(test)] +fn restore_env(key: &str, previous: Option) { + // SAFETY: caller restores test-local env mutations. + unsafe { + match previous { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -194,6 +224,55 @@ mod tests { ); } + #[test] + fn resolve_project_root_prefers_mcp_roots_over_host_env() { + let host = tempfile::TempDir::new().expect("host"); + let live = tempfile::TempDir::new().expect("live"); + let previous_claude = std::env::var_os("CLAUDE_PROJECT_DIR"); + let previous_oxcode = std::env::var_os("OXCODE_ROOT"); + let previous_workspace = std::env::var_os("WORKSPACE_FOLDER_PATHS"); + // SAFETY: test-local env mutation; restored before return. + unsafe { + std::env::set_var("CLAUDE_PROJECT_DIR", host.path()); + std::env::remove_var("OXCODE_ROOT"); + std::env::remove_var("WORKSPACE_FOLDER_PATHS"); + } + let with_roots = + resolve_project_root(None, Some(live.path().to_path_buf())).expect("mcp roots win"); + assert_eq!( + with_roots, + canonicalize_root(live.path().to_path_buf()), + "live MCP roots must beat sticky CLAUDE_PROJECT_DIR" + ); + let without_roots = resolve_project_root(None, None).expect("host env fallback"); + assert_eq!( + without_roots, + canonicalize_root(host.path().to_path_buf()), + "host env is used only when MCP roots are absent" + ); + restore_env("CLAUDE_PROJECT_DIR", previous_claude); + restore_env("OXCODE_ROOT", previous_oxcode); + restore_env("WORKSPACE_FOLDER_PATHS", previous_workspace); + } + + #[test] + fn resolve_project_root_oxcode_root_pins_over_mcp_roots() { + let pin = tempfile::TempDir::new().expect("pin"); + let live = tempfile::TempDir::new().expect("live"); + let previous = std::env::var_os("OXCODE_ROOT"); + // SAFETY: test-local env mutation; restored before return. + unsafe { + std::env::set_var("OXCODE_ROOT", pin.path()); + } + let resolved = resolve_project_root(None, Some(live.path().to_path_buf())).expect("pin"); + assert_eq!( + resolved, + canonicalize_root(pin.path().to_path_buf()), + "OXCODE_ROOT is an explicit pin above live MCP roots" + ); + restore_env("OXCODE_ROOT", previous); + } + #[test] fn file_uri_to_path_decodes_file_roots() { assert_eq!( diff --git a/prompts/arms/oxcode-mcp.md b/prompts/arms/oxcode-mcp.md index 7b74c09..7051d04 100644 --- a/prompts/arms/oxcode-mcp.md +++ b/prompts/arms/oxcode-mcp.md @@ -12,6 +12,6 @@ Available tools: - `oxcode_files { query, path?, limit? }` — keyword search over indexed files. - `oxcode_status { path? }` — index status (element/relation counts). -Selectors may be qualified names, `name:`, `element:`, or `file::`. The `path` argument defaults to the workspace project root (`OXCODE_ROOT` / `CLAUDE_PROJECT_DIR` / `WORKSPACE_FOLDER_PATHS` / MCP roots), so you can omit it — pass it explicitly if the host did not advertise a workspace. +Selectors may be qualified names, `name:`, `element:`, or `file::`. The `path` argument defaults to the workspace project root (`OXCODE_ROOT` / MCP roots / `CLAUDE_PROJECT_DIR` / `WORKSPACE_FOLDER_PATHS`), so you can omit it — pass it explicitly if the host did not advertise a workspace. Tool results are JSON with definition paths, line ranges, signatures, docstrings, source previews, and relationship call sites. Use those fields as evidence; do not open files just to recover line numbers or a short definition already present in the tool output. After `oxcode_explore`, use at most two targeted follow-up tool calls unless you are stuck. From 2bb2f0876d1085e8ada2ad7e676a944b64dae7e3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 04:23:12 +0000 Subject: [PATCH 09/11] fix(mcp): serialize roots fetch; suppress host env while refreshing Overlapping roots/list fetches could wipe a good Ready root when a late failure saw Pending. Hold a fetch mutex and keep last-good under Refreshing. On wait timeout during refresh, skip sticky host env so an older CLAUDE_PROJECT_DIR cannot win mid-switch. Co-authored-by: Michael Assaf --- crates/oxcode-cli/src/mcp/mod.rs | 12 ++-- crates/oxcode-cli/src/mcp/project_root.rs | 28 ++++++-- crates/oxcode-cli/src/mcp/roots.rs | 84 ++++++++++++++++++----- 3 files changed, 95 insertions(+), 29 deletions(-) diff --git a/crates/oxcode-cli/src/mcp/mod.rs b/crates/oxcode-cli/src/mcp/mod.rs index b7d0126..8d4da07 100644 --- a/crates/oxcode-cli/src/mcp/mod.rs +++ b/crates/oxcode-cli/src/mcp/mod.rs @@ -37,7 +37,7 @@ use rmcp::{ tool, tool_handler, tool_router, transport::stdio, }; -use roots::RootsCache; +use roots::{RootsCache, RootsWait}; use serde::Deserialize; use tokio::sync::{ Mutex, @@ -595,10 +595,14 @@ impl OxcodeServer { .is_some_and(|value| !value.is_empty()) || project_root::oxcode_root_override().is_some() { - return resolve_project_root(path, None); + return resolve_project_root(path, None, true); + } + match self.roots.wait_ready().await { + RootsWait::Ready(mcp_root) => resolve_project_root(path, mcp_root, true), + // Refresh timed out: do not fall through to sticky host env, which + // can be older than the MCP root being replaced. + RootsWait::RefreshInFlight => resolve_project_root(path, None, false), } - let mcp_root = self.roots.wait_ready().await; - resolve_project_root(path, mcp_root) } } diff --git a/crates/oxcode-cli/src/mcp/project_root.rs b/crates/oxcode-cli/src/mcp/project_root.rs index 2bf8c4b..05ec657 100644 --- a/crates/oxcode-cli/src/mcp/project_root.rs +++ b/crates/oxcode-cli/src/mcp/project_root.rs @@ -30,12 +30,17 @@ pub(crate) struct OptionalProjectRoot { /// because those vars are fixed at process start while roots refresh on /// `roots/list_changed`. /// +/// When `allow_host_env` is false (roots refresh timed out in flight), host +/// snapshots are skipped so a sticky pre-switch env cannot win over an +/// in-progress roots update. +/// /// `mcp_root` is the first client workspace root already fetched outside the /// tool handler (see [`super::roots::RootsCache`]); this never calls /// `roots/list`. pub(crate) fn resolve_project_root( path: Option, mcp_root: Option, + allow_host_env: bool, ) -> Result { let explicit = path .as_deref() @@ -48,8 +53,12 @@ pub(crate) fn resolve_project_root( pin } else if let Some(from_roots) = mcp_root { from_roots - } else if let Some(from_host) = host_env_project_root() { - from_host + } else if allow_host_env { + if let Some(from_host) = host_env_project_root() { + from_host + } else { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) + } } else { std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) }; @@ -237,19 +246,25 @@ mod tests { std::env::remove_var("OXCODE_ROOT"); std::env::remove_var("WORKSPACE_FOLDER_PATHS"); } - let with_roots = - resolve_project_root(None, Some(live.path().to_path_buf())).expect("mcp roots win"); + let with_roots = resolve_project_root(None, Some(live.path().to_path_buf()), true) + .expect("mcp roots win"); assert_eq!( with_roots, canonicalize_root(live.path().to_path_buf()), "live MCP roots must beat sticky CLAUDE_PROJECT_DIR" ); - let without_roots = resolve_project_root(None, None).expect("host env fallback"); + let without_roots = resolve_project_root(None, None, true).expect("host env fallback"); assert_eq!( without_roots, canonicalize_root(host.path().to_path_buf()), "host env is used only when MCP roots are absent" ); + let suppressed = resolve_project_root(None, None, false).expect("no host env"); + assert_ne!( + suppressed, + canonicalize_root(host.path().to_path_buf()), + "refresh-in-flight must not fall back to sticky host env" + ); restore_env("CLAUDE_PROJECT_DIR", previous_claude); restore_env("OXCODE_ROOT", previous_oxcode); restore_env("WORKSPACE_FOLDER_PATHS", previous_workspace); @@ -264,7 +279,8 @@ mod tests { unsafe { std::env::set_var("OXCODE_ROOT", pin.path()); } - let resolved = resolve_project_root(None, Some(live.path().to_path_buf())).expect("pin"); + let resolved = + resolve_project_root(None, Some(live.path().to_path_buf()), true).expect("pin"); assert_eq!( resolved, canonicalize_root(pin.path().to_path_buf()), diff --git a/crates/oxcode-cli/src/mcp/roots.rs b/crates/oxcode-cli/src/mcp/roots.rs index b0b134b..ab9aa0a 100644 --- a/crates/oxcode-cli/src/mcp/roots.rs +++ b/crates/oxcode-cli/src/mcp/roots.rs @@ -2,28 +2,40 @@ //! //! Nested `roots/list` during a tool call can hang some hosts. This cache is //! populated from `on_initialized` / `on_roots_list_changed` only; tool -//! handlers wait for a `Ready` publication. +//! handlers wait for a completed publication. use std::{path::PathBuf, sync::Arc, time::Duration}; use rmcp::{Peer, RoleServer, model::Root}; -use tokio::sync::watch; +use tokio::sync::{Mutex, watch}; use super::project_root::file_uri_to_path; /// How long an omitted-`path` resolve will wait for a roots fetch before -/// falling through without using a possibly-stale cache. +/// falling through. const ROOTS_READY_WAIT: Duration = Duration::from_millis(500); /// Published state of the client's MCP workspace roots. #[derive(Debug, Clone, PartialEq, Eq)] enum RootsState { - /// A fetch has not completed yet, or a refresh is in progress. - Pending, + /// No fetch has completed yet (cold start). + Cold, + /// A refresh is in flight; holds the last completed root for failure restore. + Refreshing(Option), /// Last completed fetch. `None` means the client has no usable root. Ready(Option), } +/// Outcome of waiting for the roots cache from a tool handler. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum RootsWait { + /// A completed fetch is available (`None` = client has no usable root). + Ready(Option), + /// Timed out while a refresh was in flight. Callers must not fall back to + /// sticky host env snapshots (those can be older than the root being replaced). + RefreshInFlight, +} + /// Outcome of one `roots/list` attempt. enum LoadOutcome { /// Client advertised no roots capability, or the RPC failed. @@ -37,30 +49,35 @@ enum LoadOutcome { } /// Single watch-backed cache for MCP roots. +/// +/// Fetches are serialized so overlapping `initialized` / `roots/list_changed` +/// notifications cannot wipe or restore the wrong root. #[derive(Clone)] pub(crate) struct RootsCache { tx: Arc>, rx: watch::Receiver, + fetch_lock: Arc>, } impl RootsCache { #[must_use] pub(crate) fn new() -> Self { - let (tx, rx) = watch::channel(RootsState::Pending); + let (tx, rx) = watch::channel(RootsState::Cold); Self { tx: Arc::new(tx), rx, + fetch_lock: Arc::new(Mutex::new(())), } } - /// Waits briefly for a completed fetch and returns the ready root, if any. + /// Waits briefly for a completed fetch. /// - /// Never issues `roots/list`. On timeout while still `Pending` (including an - /// in-flight refresh), returns `None` so the caller does not keep using a - /// stale workspace after a folder switch. - pub(crate) async fn wait_ready(&self) -> Option { + /// Never issues `roots/list`. On timeout during a refresh, returns + /// [`RootsWait::RefreshInFlight`] so callers do not use a sticky host env + /// snapshot that may predate the workspace being refreshed. + pub(crate) async fn wait_ready(&self) -> RootsWait { if let RootsState::Ready(root) = &*self.rx.borrow() { - return root.clone(); + return RootsWait::Ready(root.clone()); } let mut rx = self.rx.clone(); let finished = tokio::time::timeout( @@ -70,24 +87,30 @@ impl RootsCache { .await; match finished { Ok(Ok(state)) => match &*state { - RootsState::Ready(root) => root.clone(), - RootsState::Pending => None, + RootsState::Ready(root) => RootsWait::Ready(root.clone()), + RootsState::Cold | RootsState::Refreshing(_) => RootsWait::Ready(None), + }, + _ => match &*self.rx.borrow() { + RootsState::Ready(root) => RootsWait::Ready(root.clone()), + RootsState::Refreshing(_) => RootsWait::RefreshInFlight, + RootsState::Cold => RootsWait::Ready(None), }, - _ => None, } } /// Fetches `roots/list` and publishes [`RootsState::Ready`]. /// - /// Marks [`RootsState::Pending`] first so concurrent waiters observe the + /// Serialized: only one fetch runs at a time. Marks + /// [`RootsState::Refreshing`] first so concurrent waiters observe the /// refresh. RPC failures and unparseable URI lists restore the previous /// ready root; an explicitly empty list clears it. pub(crate) async fn fetch(&self, peer: &Peer) { + let _guard = self.fetch_lock.lock().await; let previous = match &*self.rx.borrow() { - RootsState::Ready(root) => root.clone(), - RootsState::Pending => None, + RootsState::Ready(root) | RootsState::Refreshing(root) => root.clone(), + RootsState::Cold => None, }; - let _ = self.tx.send(RootsState::Pending); + let _ = self.tx.send(RootsState::Refreshing(previous.clone())); let published = match self.load_root(peer).await { LoadOutcome::Empty => None, LoadOutcome::Parsed(path) => Some(path), @@ -144,4 +167,27 @@ mod tests { LoadOutcome::Parsed(path) if path == project.path() )); } + + #[tokio::test] + async fn wait_ready_timeout_during_refresh_is_in_flight() { + let cache = RootsCache::new(); + let _ = cache + .tx + .send(RootsState::Refreshing(Some(PathBuf::from("/old")))); + assert_eq!(cache.wait_ready().await, RootsWait::RefreshInFlight); + } + + #[tokio::test] + async fn wait_ready_timeout_while_cold_allows_host_env_fallback() { + let cache = RootsCache::new(); + assert_eq!(cache.wait_ready().await, RootsWait::Ready(None)); + } + + #[tokio::test] + async fn wait_ready_returns_ready_root() { + let cache = RootsCache::new(); + let root = PathBuf::from("/project"); + let _ = cache.tx.send(RootsState::Ready(Some(root.clone()))); + assert_eq!(cache.wait_ready().await, RootsWait::Ready(Some(root))); + } } From 1ab8c7ba20c2bce79ef6ce3e99b33d12b50fad5c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 04:26:10 +0000 Subject: [PATCH 10/11] fix(mcp): allow host env during cold-start roots timeout RefreshInFlight (and host-env suppression) only applies when replacing a previously ready MCP root. Refreshing(None) on first fetch still falls through to CLAUDE_PROJECT_DIR / WORKSPACE_FOLDER_PATHS. Co-authored-by: Michael Assaf --- crates/oxcode-cli/src/mcp/roots.rs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/oxcode-cli/src/mcp/roots.rs b/crates/oxcode-cli/src/mcp/roots.rs index ab9aa0a..ee7d796 100644 --- a/crates/oxcode-cli/src/mcp/roots.rs +++ b/crates/oxcode-cli/src/mcp/roots.rs @@ -31,8 +31,10 @@ enum RootsState { pub(crate) enum RootsWait { /// A completed fetch is available (`None` = client has no usable root). Ready(Option), - /// Timed out while a refresh was in flight. Callers must not fall back to - /// sticky host env snapshots (those can be older than the root being replaced). + /// Timed out while replacing a previously ready root. Callers must not fall + /// back to sticky host env snapshots (those can be older than the root being + /// replaced). Cold-start / `Refreshing(None)` timeouts are [`Ready`]`(None)` + /// instead, so host env remains available. RefreshInFlight, } @@ -92,8 +94,11 @@ impl RootsCache { }, _ => match &*self.rx.borrow() { RootsState::Ready(root) => RootsWait::Ready(root.clone()), - RootsState::Refreshing(_) => RootsWait::RefreshInFlight, - RootsState::Cold => RootsWait::Ready(None), + // Only suppress host env when replacing a previously ready root. + // Cold start / Refreshing(None) still allows CLAUDE_PROJECT_DIR + // and WORKSPACE_FOLDER_PATHS if roots/list is slow. + RootsState::Refreshing(Some(_)) => RootsWait::RefreshInFlight, + RootsState::Refreshing(None) | RootsState::Cold => RootsWait::Ready(None), }, } } @@ -177,6 +182,17 @@ mod tests { assert_eq!(cache.wait_ready().await, RootsWait::RefreshInFlight); } + #[tokio::test] + async fn wait_ready_timeout_on_first_fetch_allows_host_env() { + let cache = RootsCache::new(); + let _ = cache.tx.send(RootsState::Refreshing(None)); + assert_eq!( + cache.wait_ready().await, + RootsWait::Ready(None), + "cold-start Refreshing(None) must not suppress host env" + ); + } + #[tokio::test] async fn wait_ready_timeout_while_cold_allows_host_env_fallback() { let cache = RootsCache::new(); From 2becdd6353e76ff2040d57c43c6eaff8ba44446c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Jul 2026 04:30:06 +0000 Subject: [PATCH 11/11] fix(mcp): share env lock across project-root unit tests Parallel tests mutated CLAUDE_PROJECT_DIR / OXCODE_ROOT without the same mutex as integration tests, racing resolve_project_root assertions in CI. Co-authored-by: Michael Assaf --- .../oxcode-cli/src/mcp/integration_tests.rs | 10 ++----- crates/oxcode-cli/src/mcp/project_root.rs | 29 ++++++++++++------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/crates/oxcode-cli/src/mcp/integration_tests.rs b/crates/oxcode-cli/src/mcp/integration_tests.rs index 449275d..73b0560 100644 --- a/crates/oxcode-cli/src/mcp/integration_tests.rs +++ b/crates/oxcode-cli/src/mcp/integration_tests.rs @@ -6,10 +6,7 @@ //! and the task lifecycle. The cross-process guarantee is proven separately by //! `tests/multiprocess.rs` (real spawned processes). -use std::{ - sync::{Mutex, MutexGuard}, - time::Duration, -}; +use std::{sync::MutexGuard, time::Duration}; use rmcp::{ ClientHandler, RoleClient, @@ -22,13 +19,10 @@ use rmcp::{ }; use super::{ - project_root::{canonicalize_root, oxcode_root_override}, + project_root::{PROJECT_ROOT_ENV_LOCK, canonicalize_root, oxcode_root_override}, *, }; -/// Serializes tests that mutate process-global project-root env vars. -static PROJECT_ROOT_ENV_LOCK: Mutex<()> = Mutex::new(()); - /// Env keys consulted for omitted-`path` defaults, for save/restore around tests. const PROJECT_ROOT_ENV_KEYS: &[&str] = &[ "OXCODE_ROOT", diff --git a/crates/oxcode-cli/src/mcp/project_root.rs b/crates/oxcode-cli/src/mcp/project_root.rs index 05ec657..c52f676 100644 --- a/crates/oxcode-cli/src/mcp/project_root.rs +++ b/crates/oxcode-cli/src/mcp/project_root.rs @@ -199,9 +199,13 @@ fn hex_nibble(byte: u8) -> Option { } } +/// Serializes tests that mutate project-root / HOME env vars. +#[cfg(test)] +pub(crate) static PROJECT_ROOT_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[cfg(test)] fn restore_env(key: &str, previous: Option) { - // SAFETY: caller restores test-local env mutations. + // SAFETY: caller holds [`PROJECT_ROOT_ENV_LOCK`]. unsafe { match previous { Some(value) => std::env::set_var(key, value), @@ -235,12 +239,15 @@ mod tests { #[test] fn resolve_project_root_prefers_mcp_roots_over_host_env() { + let _lock = PROJECT_ROOT_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let host = tempfile::TempDir::new().expect("host"); let live = tempfile::TempDir::new().expect("live"); let previous_claude = std::env::var_os("CLAUDE_PROJECT_DIR"); let previous_oxcode = std::env::var_os("OXCODE_ROOT"); let previous_workspace = std::env::var_os("WORKSPACE_FOLDER_PATHS"); - // SAFETY: test-local env mutation; restored before return. + // SAFETY: held exclusively via PROJECT_ROOT_ENV_LOCK. unsafe { std::env::set_var("CLAUDE_PROJECT_DIR", host.path()); std::env::remove_var("OXCODE_ROOT"); @@ -272,10 +279,13 @@ mod tests { #[test] fn resolve_project_root_oxcode_root_pins_over_mcp_roots() { + let _lock = PROJECT_ROOT_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let pin = tempfile::TempDir::new().expect("pin"); let live = tempfile::TempDir::new().expect("live"); let previous = std::env::var_os("OXCODE_ROOT"); - // SAFETY: test-local env mutation; restored before return. + // SAFETY: held exclusively via PROJECT_ROOT_ENV_LOCK. unsafe { std::env::set_var("OXCODE_ROOT", pin.path()); } @@ -308,20 +318,17 @@ mod tests { #[test] fn is_home_directory_matches_home_env() { + let _lock = PROJECT_ROOT_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let home = tempfile::TempDir::new().expect("home"); let previous_home = std::env::var_os("HOME"); - // SAFETY: test-local HOME mutation; restored before return. + // SAFETY: held exclusively via PROJECT_ROOT_ENV_LOCK. unsafe { std::env::set_var("HOME", home.path()); } assert!(is_home_directory(home.path())); assert!(!is_home_directory(&home.path().join("opt/jinttai"))); - // SAFETY: restore prior HOME. - unsafe { - match previous_home { - Some(value) => std::env::set_var("HOME", value), - None => std::env::remove_var("HOME"), - } - } + restore_env("HOME", previous_home); } }