Skip to main content

pathfinder_lib/
server.rs

1//! Pathfinder MCP Server — tool registration and dispatch.
2//!
3//! Implements `rmcp::ServerHandler` for all Pathfinder discovery & navigation tools.
4//!
5//! # Module Layout
6//! - [`helpers`] — error conversion, stub builder, language detection
7//! - [`types`] — all parameter and response structs
8//! - [`tools`] — handler logic:
9//!   - The `#[tool_router]` impl block below registers 7 consolidated tools:
10//!     `explore`, `search`, `read`, `inspect`, `locate`, `trace`, `health`.
11//!   - Submodules (`search`, `repo_map`, `navigation`, etc.) contain
12//!     the underlying implementations delegated to by these handlers.
13
14/// A cached probe result.
15///
16/// Positive and negative entries are cached and evaluated based on dynamic
17/// intervals that scale with the LSP process age.
18#[derive(Clone)]
19pub(crate) struct ProbeCacheEntry {
20    /// Whether the probe succeeded.
21    pub(crate) success: bool,
22    /// Whether call hierarchy was verified.
23    pub(crate) call_hierarchy_verified: bool,
24    /// When this entry was created.
25    pub(crate) created_at: std::time::Instant,
26}
27
28impl ProbeCacheEntry {
29    pub(crate) fn new(success: bool, call_hierarchy_verified: bool) -> Self {
30        Self {
31            success,
32            call_hierarchy_verified,
33            created_at: std::time::Instant::now(),
34        }
35    }
36
37    /// How old is this cache entry in seconds?
38    pub(crate) fn age_secs(&self) -> u64 {
39        self.created_at.elapsed().as_secs()
40    }
41}
42
43mod helpers;
44mod tools;
45/// Module containing type definitions.
46pub mod types;
47
48use types::{
49    ExploreParams, HealthParams, InspectParams, LocateParams, ReadParams, SearchParams, TraceParams,
50};
51
52use pathfinder_common::config::PathfinderConfig;
53use pathfinder_common::sandbox::Sandbox;
54use pathfinder_common::types::WorkspaceRoot;
55use pathfinder_lsp::{Lawyer, LspClient, NoOpLawyer};
56use pathfinder_search::{RipgrepScout, Scout};
57use pathfinder_treesitter::{Surgeon, TreeSitterSurgeon};
58
59use rmcp::handler::server::tool::ToolRouter;
60use rmcp::handler::server::wrapper::Parameters;
61use rmcp::model::{ErrorData, Implementation, ServerCapabilities, ServerInfo};
62use rmcp::{tool, tool_handler, tool_router, ServerHandler};
63
64use std::sync::Arc;
65
66/// The main Pathfinder MCP server.
67///
68/// Holds shared workspace state and dispatches MCP tool calls.
69#[derive(Clone)]
70pub struct PathfinderServer {
71    workspace_root: Arc<WorkspaceRoot>,
72    sandbox: Arc<Sandbox>,
73    scout: Arc<dyn Scout>,
74    surgeon: Arc<dyn Surgeon>,
75    lawyer: Arc<dyn Lawyer>,
76    // Populated by `#[tool_router]` and consumed through the generated
77    // `tool_handler` trait impl. The compiler's dead-code pass cannot follow
78    // the read path across the proc-macro boundary, so we suppress the lint.
79    #[expect(dead_code)]
80    tool_router: ToolRouter<Self>,
81    /// Cache of probe results per language to avoid redundant LSP calls.
82    ///
83    /// Cache validity is evaluated dynamically based on `get_probe_interval()`,
84    /// which scales with the LSP process age (10s for first 60s, 30s for
85    /// 60-300s, 120s for 300s+). The effective threshold is
86    /// `min(30, probe_interval)` seconds.
87    probe_cache: Arc<std::sync::Mutex<std::collections::HashMap<String, ProbeCacheEntry>>>,
88    /// Tracking map of when each LSP was first detected as connected (uptime is Some).
89    /// Used for dynamic probe interval ramp-up schedule.
90    lsp_started_at: Arc<std::sync::Mutex<std::collections::HashMap<String, std::time::Instant>>>,
91}
92
93impl PathfinderServer {
94    /// Create a new Pathfinder server backed by the real Ripgrep scout, Tree-sitter
95    /// surgeon, and `LspClient` for LSP operations.
96    ///
97    /// Zero-Config language detection (PRD §6.5) runs synchronously during construction.
98    /// LSP processes are started **lazily** — only when the first LSP-dependent tool call
99    /// is made for a given language.
100    ///
101    /// If Zero-Config detection fails (e.g., unreadable workspace directory), the server
102    /// falls back to `NoOpLawyer` and logs a warning. All tools remain functional in
103    /// degraded mode.
104    #[must_use]
105    pub async fn new(workspace_root: WorkspaceRoot, config: PathfinderConfig) -> Self {
106        let sandbox = Sandbox::new(workspace_root.path(), &config.sandbox);
107
108        let lawyer: Arc<dyn Lawyer> =
109            match LspClient::new(workspace_root.path(), Arc::new(config.clone())).await {
110                Ok(client) => {
111                    // Kick off background initialization so LSP processes are
112                    // already loading while the agent issues its first non-LSP
113                    // tool calls (explore, search, etc.).
114                    client.warm_start();
115                    tracing::info!(
116                        workspace = %workspace_root.path().display(),
117                        "LspClient initialised (warm start in progress)"
118                    );
119                    Arc::new(client)
120                }
121                Err(e) => {
122                    tracing::warn!(
123                        error = %e,
124                        "LSP Zero-Config detection failed — degraded mode (NoOpLawyer)"
125                    );
126                    Arc::new(NoOpLawyer)
127                }
128            };
129
130        Self::with_all_engines(
131            workspace_root,
132            config,
133            sandbox,
134            Arc::new(RipgrepScout),
135            Arc::new(TreeSitterSurgeon::new(100)), // Cache capacity of 100 files
136            lawyer,
137        )
138    }
139
140    /// Create a server with injected Scout and Surgeon engines (for testing).
141    ///
142    /// Uses a `NoOpLawyer` for LSP operations — keeps existing tests unchanged.
143    #[must_use]
144    #[cfg_attr(not(test), allow(dead_code))]
145    pub fn with_engines(
146        workspace_root: WorkspaceRoot,
147        config: PathfinderConfig,
148        sandbox: Sandbox,
149        scout: Arc<dyn Scout>,
150        surgeon: Arc<dyn Surgeon>,
151    ) -> Self {
152        Self::with_all_engines(
153            workspace_root,
154            config,
155            sandbox,
156            scout,
157            surgeon,
158            Arc::new(NoOpLawyer),
159        )
160    }
161
162    /// Create a server with all three engines injected (for testing with a `MockLawyer`).
163    #[must_use]
164    #[allow(clippy::needless_pass_by_value)] // Preserve API compatibility; 20+ call sites in tests
165    pub fn with_all_engines(
166        workspace_root: WorkspaceRoot,
167        _config: PathfinderConfig,
168        sandbox: Sandbox,
169        scout: Arc<dyn Scout>,
170        surgeon: Arc<dyn Surgeon>,
171        lawyer: Arc<dyn Lawyer>,
172    ) -> Self {
173        Self {
174            workspace_root: Arc::new(workspace_root),
175            sandbox: Arc::new(sandbox),
176            scout,
177            surgeon,
178            lawyer,
179            tool_router: Self::tool_router(),
180            probe_cache: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
181            lsp_started_at: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
182        }
183    }
184}
185
186// ── Tool Router (defines all 7 consolidated tools) ─────────────────
187
188#[tool_router]
189impl PathfinderServer {
190    #[tool(
191        name = "explore",
192        description = "Get the structural skeleton of the project — directory tree, file listing, or full symbol hierarchy.
193
194Use when: Exploring project structure, discovering available symbols, or planning navigation.
195Alternative: Use `read` for a single file's content. Use `search(mode=\"symbol\")` to locate a symbol by name.
196
197IMPORTANT: Copy-paste the exact semantic paths from the output into other Pathfinder tools.
198
199Response format: The skeleton (directory tree, file list, or symbol hierarchy) is in the TEXT
200content. Structured content contains only metadata (coverage_percent, tech_stack, version_hashes,
201mode, dirs_scanned). Always read the text content for the actual output.
202
203Metadata fields:
204- `mode`: Indicates which detail mode was used: \"structure\", \"files\", or \"symbols\".
205- `dirs_scanned`: Number of directories scanned (only present for `detail=\"structure\"`).
206- `files_scanned`: Number of source files scanned (always 0 for structure mode).
207  Check `mode` and `dirs_scanned` to confirm structure-mode calls succeeded.
208
209Parameter guidance:
210- `detail`: Controls output verbosity.
211  - `\"structure\"` — directory tree + package manager files only (cheapest). Token cap: 4,000.
212  - `\"files\"` — directory tree + all filenames (no symbols). Token cap: 8,000.
213  - `\"symbols\"` (default) — full AST symbol hierarchy. Uses provided max_tokens (default 16,000).
214- `depth=3` (default): Increase for deeply-nested monorepos.
215- `max_tokens=16000` (default for symbols): Auto-scales up to 48,000 for large repos.
216  NOTE: structure and files modes ignore this value — they use their own caps (4,000 / 8,000).
217- `visibility`: `\"public\"` (default) or `\"all\"` (includes private/internal).
218
219Example: `explore(path=\"src/\", detail=\"files\", depth=5)`"
220    )]
221    async fn explore(
222        &self,
223        Parameters(params): Parameters<ExploreParams>,
224    ) -> Result<rmcp::model::CallToolResult, ErrorData> {
225        self.get_repo_map_impl(params).await
226    }
227
228    #[tool(
229        name = "search",
230        description = "Search for text patterns, regex, or resolve symbol names across the codebase.
231
232Use when: Finding text/patterns, locating function calls, or resolving a bare symbol name to its semantic path.
233
234Parameter guidance:
235- `mode`: Controls search behavior.
236  - `\"text\"` (default) — literal text search.
237  - `\"regex\"` — regex pattern search.
238  - `\"symbol\"` — resolve bare symbol name to `file::symbol` semantic paths.
239    Use `kind` to filter by symbol type. Accepted values (case-insensitive):
240    Canonical: function, class, struct, interface, enum, constant, module, impl.
241    Aliases: method/fn → function; trait → interface; const/static/let → constant;
242    mod/namespace → module; class also matches struct and interface.
243    Invalid kind values return an error listing accepted values.
244- `path_glob`: Limit scope (e.g., `\"**/*.rs\"`).
245- `max_results=50` (default): Cap returned matches. Applies to all modes including `symbol`.
246- `known_files`: Suppress full content for files already in context.
247
248Examples:
249- `search(query=\"login\", path_glob=\"**/*.rs\")` — text search
250- `search(query=\"TODO|FIXME\", mode=\"regex\")` — regex search
251- `search(query=\"AuthService\", mode=\"symbol\", kind=\"class\")` — symbol lookup"
252    )]
253    async fn search(
254        &self,
255        Parameters(params): Parameters<SearchParams>,
256    ) -> Result<rmcp::model::CallToolResult, ErrorData> {
257        self.search_impl(params).await
258    }
259
260    #[tool(
261        name = "read",
262        description = "Read file contents — single file or batch. Auto-detects source vs config files.
263
264Use when: Reading any file. Source files (.rs, .ts, .go, .py, .vue, .js, .java) get AST-parsed content. Config files (.yaml, .toml, .json, .env) get raw content.
265
266Parameter guidance:
267- `filepath`: Single file path. Use for reading one file.
268- `paths`: Array of file paths (max 10). Use for batch reading.
269  Exactly one of `filepath` or `paths` must be provided.
270- `detail_level`: `\"source_only\"` (lowest tokens), `\"compact\"` (default), `\"symbols\"` (tree only), `\"full\"` (source + nested AST).
271- `start_line`/`end_line`: Restrict output to a line range.
272- `max_lines_per_file`: Maximum lines returned per file (defaults to 500). Applies to batch mode and config/raw files.
273
274Examples:
275- `read(filepath=\"src/auth.ts\", detail_level=\"compact\")` — single source file
276- `read(filepath=\".env\")` — config file (raw content)
277- `read(paths=[\"src/auth.ts\", \"src/config.ts\"])` — batch read"
278    )]
279    async fn read(
280        &self,
281        Parameters(params): Parameters<ReadParams>,
282    ) -> Result<rmcp::model::CallToolResult, ErrorData> {
283        self.read_impl(params).await
284    }
285
286    #[tool(
287        name = "inspect",
288        description = r#"Extract a symbol's source code by semantic path (single or batch), optionally with its dependency graph.
289
290Use when: You know the exact symbol and want its source code. Optionally includes signatures of all functions it calls.
291Alternative: Use `read` for full file content.
292
293IMPORTANT: `semantic_path` MUST include file path + '::' (e.g., `src/auth.ts::AuthService.login`).
294
295Parameter guidance:
296- `semantic_path`: Single semantic path.
297- `semantic_paths`: Array of semantic paths (max 10) for batch inspection.
298  Provide either `semantic_path` or `semantic_paths`.
299- `include_dependencies=false` (default): Source code only (fast, Tree-sitter).
300- `include_dependencies=true`: Source + callee signatures (LSP-powered, may take 5–30s on first call).
301- `max_dependencies=50` (default): Cap dependency output.
302
303Examples:
304- `inspect(semantic_path="src/auth.ts::AuthService.login")` — source only
305- `inspect(semantic_paths=["src/auth.ts::AuthService.login", "src/auth.ts::AuthService.logout"])` — batch inspect"#
306    )]
307    async fn inspect(
308        &self,
309        Parameters(params): Parameters<InspectParams>,
310    ) -> Result<rmcp::model::CallToolResult, ErrorData> {
311        self.inspect_impl(params).await
312    }
313
314    #[tool(
315        name = "locate",
316        description = r#"Jump to a symbol's definition, or resolve a file+line to its semantic path (single or batch).
317
318Use when: Navigating to where a symbol is defined, or converting stack trace locations to semantic paths.
319
320Three modes (auto-detected from input):
3211. **Definition lookup**: Provide `semantic_path` → returns definition file, line, column, and code preview.
3222. **Semantic path resolution**: Provide `file` + `line` → returns the `file::symbol` semantic path of the enclosing symbol.
3233. **Batch locate**: Provide `locations` array containing up to 10 entries (each with either `semantic_path` or `file` + `line`).
324
325LSP-powered with ripgrep fallback. Check `degraded` in response.
326
327Examples:
328- `locate(semantic_path="src/auth.ts::AuthService.login")` — jump to definition
329- `locate(file="src/auth.ts", line=42)` — resolve to semantic path
330- `locate(locations=[{semantic_path: "src/auth.ts::AuthService.login"}, {file: "src/auth.ts", line: 42}])` — batch locate"#
331    )]
332    async fn locate(
333        &self,
334        Parameters(params): Parameters<LocateParams>,
335    ) -> Result<rmcp::model::CallToolResult, ErrorData> {
336        self.locate_impl(params).await
337    }
338
339    #[tool(
340        name = "trace",
341        description = "Trace a symbol's relationships — callers/callees, all references, or full overview.
342
343ALWAYS run this before recommending a refactor to check for unexpected callers.
344
345Use when: Understanding blast radius, finding all usages, or getting a complete picture before refactoring.
346
347Parameter guidance:
348- `scope`: What to trace.
349  - `\"callers\"` (default) — call hierarchy: who calls this and what it calls. Use `max_depth` to control traversal.
350  - `\"references\"` — all references: calls, imports, type annotations, field access.
351  - `\"overview\"` — combined: source + callers + callees + references in one call. Uses `max_references` for both callers/callees and references caps.
352- `max_depth=3` (default): For call hierarchy traversal. Increase to 4-5 for large API changes.
353- `max_references=50` (default): Cap output for all scopes. In `overview` mode, applies to both caller/callee and reference limits.
354- `offset`: Pagination offset. Applies to `scope=\"references\"` only; ignored for `callers` and `overview`.
355
356LSP-powered. Response field semantics when `degraded=true`:
357- `incoming`/`outgoing` are `null` — LSP was unavailable and no heuristic results found. Callers are **unknown**.
358- `incoming`/`outgoing` contain results with `confidence: \"heuristic\"` — grep-based fallback found candidates (may include false positives).
359- `incoming`/`outgoing` are `[]` — LSP confirmed zero callers/callees exist (only when `degraded=false`).
360Never treat `null` as \"no callers\" — it means the answer is unknown. Use `search` as a fallback.
361
362⚠️ CRITICAL — null vs empty array are NOT equivalent:
363  null  = UNKNOWN (degraded — callers may exist but LSP couldn't confirm)
364  []    = CONFIRMED ZERO (LSP verified — safe to conclude no callers)
365Mistaking null for \"no callers\" leads to dangerous refactoring decisions.
366
367Examples:
368- `trace(semantic_path=\"src/auth.ts::AuthService.login\")` — callers/callees
369- `trace(semantic_path=\"src/auth.ts::AuthService.login\", scope=\"references\")` — all usages
370- `trace(semantic_path=\"src/auth.ts::AuthService.login\", scope=\"overview\")` — full picture"
371    )]
372    async fn trace(
373        &self,
374        Parameters(params): Parameters<TraceParams>,
375    ) -> Result<rmcp::model::CallToolResult, ErrorData> {
376        self.trace_impl(params).await
377    }
378
379    #[tool(
380        name = "health",
381        description = "Check LSP health and readiness per language.
382
383Use when: Diagnosing why navigation tools returned degraded results, or checking LSP status.
384
385Pass `language` to check a specific language, or omit to check all.
386Pass `action=\"restart\"` with `language` to force-restart a stuck LSP process.
387Pass `force_probe=true` to force a live liveness check regardless of cache age.
388
389Example: `health(language=\"rust\", force_probe=true)`"
390    )]
391    async fn health(
392        &self,
393        Parameters(params): Parameters<HealthParams>,
394    ) -> Result<rmcp::model::CallToolResult, rmcp::model::ErrorData> {
395        self.health_impl(params).await
396    }
397}
398
399// ── ServerHandler trait impl ────────────────────────────────────────
400
401#[tool_handler]
402impl ServerHandler for PathfinderServer {
403    fn get_info(&self) -> ServerInfo {
404        ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
405            .with_server_info(Implementation::new("pathfinder", env!("CARGO_PKG_VERSION")))
406    }
407}
408
409#[cfg(test)]
410#[path = "server_test.rs"]
411mod tests;