Skip to main content

lean_ctx/server/
server_handler.rs

1//! `rmcp::ServerHandler` trait implementation for [`LeanCtxServer`].
2//!
3//! Split out of `server/mod.rs`; `use super::*` re-imports the parent module’s
4//! aliases and sibling submodules. Methods attach to `LeanCtxServer` regardless
5//! of which module the impl block lives in.
6
7#[allow(clippy::wildcard_imports)]
8use super::*;
9
10impl ServerHandler for LeanCtxServer {
11    fn get_info(&self) -> ServerInfo {
12        let capabilities = ServerCapabilities::builder()
13            .enable_tools()
14            .enable_resources()
15            .enable_resources_subscribe()
16            .enable_prompts()
17            .build();
18
19        let config = crate::core::config::Config::load();
20        let level = crate::core::config::CompressionLevel::effective(&config);
21        let _ = crate::core::terse::rules_inject::inject(&level);
22
23        let instructions = crate::instructions::build_instructions(CrpMode::effective());
24
25        InitializeResult::new(capabilities)
26            .with_server_info(Implementation::new("lean-ctx", env!("CARGO_PKG_VERSION")))
27            .with_instructions(instructions)
28    }
29
30    async fn initialize(
31        &self,
32        request: InitializeRequestParams,
33        context: RequestContext<RoleServer>,
34    ) -> Result<InitializeResult, ErrorData> {
35        let name = request.client_info.name.clone();
36        tracing::info!("MCP client connected: {:?}", name);
37        *self.client_name.write().await = name.clone();
38        *self.peer.write().await = Some(context.peer.clone());
39
40        if self.session_mode != crate::tools::SessionMode::Shared {
41            crate::core::budget_tracker::BudgetTracker::global().reset();
42            if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
43                let radar = data_dir.join("context_radar.jsonl");
44                if radar.exists() {
45                    let prev = data_dir.join("context_radar.prev.jsonl");
46                    let _ = std::fs::rename(&radar, &prev);
47                }
48            }
49        }
50
51        let has_roots = request.capabilities.roots.is_some();
52        self.has_client_roots
53            .store(has_roots, std::sync::atomic::Ordering::Relaxed);
54        if has_roots {
55            tracing::info!("Client supports MCP roots/list — will resolve on first tool call");
56        }
57
58        let env_root = roots::root_from_env();
59        let derived_root = derive_project_root_from_cwd();
60        let effective_root = env_root.or(derived_root);
61
62        let cwd_str = std::env::current_dir()
63            .ok()
64            .map(|p| p.to_string_lossy().to_string())
65            .unwrap_or_default();
66        {
67            let mut session = self.session.write().await;
68            if !cwd_str.is_empty() {
69                session.shell_cwd = Some(cwd_str.clone());
70            }
71            if let Some(ref root) = effective_root {
72                session.project_root = Some(root.clone());
73                tracing::info!("Project root set to: {root}");
74            } else if let Some(ref root) = session.project_root {
75                // A previously persisted session may carry a contaminated root
76                // (e.g. HOME from an older build or a client that reported HOME
77                // as its workspace). Drop it unless it is a real, safe project
78                // dir — otherwise PROJECT MEMORY leaks across projects.
79                let root_path = std::path::Path::new(root);
80                let root_has_marker = has_project_marker(root_path);
81                let root_str = root_path.to_string_lossy();
82                let root_suspicious = crate::core::pathutil::is_broad_or_unsafe_root(root_path)
83                    || root_str.contains("/var/folders/")
84                    || root_str.contains("/tmp/")
85                    || root_str.contains("\\AppData\\Local\\Temp")
86                    || root_str.contains("\\Temp\\");
87                if root_suspicious && !root_has_marker {
88                    tracing::info!("Dropping suspicious persisted project root: {root}");
89                    session.project_root = None;
90                }
91            }
92            let cfg_extra = crate::core::config::Config::load().extra_roots;
93            if !cfg_extra.is_empty() {
94                let existing: std::collections::HashSet<_> =
95                    session.extra_roots.iter().cloned().collect();
96                for r in cfg_extra {
97                    if !existing.contains(&r) {
98                        session.extra_roots.push(r);
99                    }
100                }
101            }
102            if self.session_mode == crate::tools::SessionMode::Shared {
103                if let Some(ref root) = session.project_root {
104                    if let Some(ref rt) = self.context_os {
105                        rt.shared_sessions.persist_best_effort(
106                            root,
107                            &self.workspace_id,
108                            &self.channel_id,
109                            &session,
110                        );
111                        rt.metrics.record_session_persisted();
112                    }
113                }
114            } else if let Err(e) = session.save() {
115                tracing::warn!("lean-ctx: failed to persist session state: {e}");
116            }
117        }
118
119        let extra_roots_snapshot = self.session.read().await.extra_roots.clone();
120        if let Some(ref root) = effective_root {
121            crate::core::index_orchestrator::ensure_all_background(root);
122            if !extra_roots_snapshot.is_empty() {
123                let r = root.clone();
124                std::thread::spawn(move || {
125                    crate::core::index_orchestrator::ensure_extra_roots_background(
126                        &r,
127                        &extra_roots_snapshot,
128                    );
129                });
130            }
131        }
132
133        let agent_name = name.clone();
134        let agent_root = effective_root.clone().unwrap_or_default();
135        let agent_id_handle = self.agent_id.clone();
136        tokio::task::spawn_blocking(move || {
137            if std::env::var("LEAN_CTX_HEADLESS").is_ok() {
138                return;
139            }
140
141            // Avoid startup stampedes when multiple agent sessions initialize at once.
142            // These are best-effort maintenance tasks; it's fine to skip if another
143            // lean-ctx instance is already doing them.
144            let maintenance = crate::core::startup_guard::try_acquire_lock(
145                "startup-maintenance",
146                std::time::Duration::from_secs(2),
147                std::time::Duration::from_mins(2),
148            );
149            if maintenance.is_some() {
150                if let Some(home) = dirs::home_dir() {
151                    let _ = crate::rules_inject::inject_all_rules(&home);
152                }
153                crate::hooks::refresh_installed_hooks();
154                crate::core::version_check::check_background();
155                // Enforce the on-disk budget: prune accumulated quarantined BM25
156                // indexes and cap the archive FTS DB (#2364). Silent (tracing
157                // only) so it never corrupts the MCP stdio protocol.
158                let _ = crate::core::storage_maintenance::run_quiet();
159            }
160            drop(maintenance);
161
162            if !agent_root.is_empty() {
163                let heuristic_role = match agent_name.to_lowercase().as_str() {
164                    n if n.contains("cursor") => Some("coder"),
165                    n if n.contains("claude") => Some("coder"),
166                    n if n.contains("codex") => Some("coder"),
167                    n if n.contains("antigravity") || n.contains("gemini") => Some("coder"),
168                    n if n.contains("review") => Some("reviewer"),
169                    n if n.contains("test") => Some("debugger"),
170                    _ => None,
171                };
172                let env_role = std::env::var("LEAN_CTX_ROLE")
173                    .or_else(|_| std::env::var("LEAN_CTX_AGENT_ROLE"))
174                    .ok();
175                let effective_role = env_role.as_deref().or(heuristic_role).unwrap_or("coder");
176
177                let _ = crate::core::roles::set_active_role_with_source(effective_role, true);
178
179                let mut registry = crate::core::agents::AgentRegistry::load_or_create();
180                registry.cleanup_stale(24);
181                let id = registry.register("mcp", Some(effective_role), &agent_root);
182                let _ = registry.save();
183                if let Ok(mut guard) = agent_id_handle.try_write() {
184                    *guard = Some(id);
185                }
186            }
187        });
188
189        let client_caps = crate::core::client_capabilities::ClientMcpCapabilities::detect(&name);
190        tracing::info!("Client capabilities: {}", client_caps.format_summary());
191
192        {
193            let cfg = crate::core::config::Config::load();
194            let cats = cfg.default_tool_categories_effective();
195            dynamic_tools::init_from_config(&cats);
196        }
197
198        if client_caps.dynamic_tools {
199            if let Ok(mut dt) = dynamic_tools::global().lock() {
200                dt.set_supports_list_changed(true);
201            }
202        }
203        if let Some(max) = client_caps.max_tools {
204            if let Ok(mut dt) = dynamic_tools::global().lock() {
205                dt.set_supports_list_changed(true);
206                if max < 100 {
207                    dt.unload_category(dynamic_tools::ToolCategory::Debug);
208                    dt.unload_category(dynamic_tools::ToolCategory::Memory);
209                }
210            }
211        }
212
213        crate::core::client_capabilities::set_detected(&client_caps);
214
215        let instructions =
216            crate::instructions::build_instructions_with_client(CrpMode::effective(), &name);
217
218        let capabilities = match (client_caps.resources, client_caps.prompts) {
219            (true, true) => ServerCapabilities::builder()
220                .enable_tools()
221                .enable_resources()
222                .enable_resources_subscribe()
223                .enable_prompts()
224                .build(),
225            (true, false) => ServerCapabilities::builder()
226                .enable_tools()
227                .enable_resources()
228                .enable_resources_subscribe()
229                .build(),
230            (false, true) => ServerCapabilities::builder()
231                .enable_tools()
232                .enable_prompts()
233                .build(),
234            (false, false) => ServerCapabilities::builder().enable_tools().build(),
235        };
236
237        Ok(InitializeResult::new(capabilities)
238            .with_server_info(Implementation::new("lean-ctx", env!("CARGO_PKG_VERSION")))
239            .with_instructions(instructions))
240    }
241
242    async fn list_tools(
243        &self,
244        _request: Option<PaginatedRequestParams>,
245        _context: RequestContext<RoleServer>,
246    ) -> Result<ListToolsResult, ErrorData> {
247        let all_tools = if crate::tool_defs::is_full_mode() {
248            if let Some(ref reg) = self.registry {
249                reg.tool_defs()
250            } else {
251                crate::tool_defs::granular_tool_defs()
252            }
253        } else if std::env::var("LEAN_CTX_UNIFIED").is_ok() {
254            crate::tool_defs::unified_tool_defs()
255        } else if let Some(ref reg) = self.registry {
256            let core_names = crate::tool_defs::core_tool_names();
257            reg.tool_defs()
258                .into_iter()
259                .filter(|t| core_names.contains(&t.name.as_ref()))
260                .collect()
261        } else {
262            crate::tool_defs::lazy_tool_defs()
263        };
264
265        let cfg = crate::core::config::Config::load();
266        let disabled = cfg.disabled_tools_effective();
267        let tool_profile = cfg.tool_profile_effective();
268        let client = self.client_name.read().await.clone();
269        let is_zed = !client.is_empty() && client.to_lowercase().contains("zed");
270
271        let active_role = crate::core::roles::active_role();
272        let tools: Vec<_> = all_tools
273            .into_iter()
274            .filter(|t| {
275                let name = t.name.as_ref();
276                if !tool_profile.is_tool_enabled(name) {
277                    return false;
278                }
279                if !disabled.is_empty() && disabled.iter().any(|d| d.as_str() == name) {
280                    return false;
281                }
282                if is_zed && name == "ctx_edit" {
283                    return false;
284                }
285                if !active_role.is_tool_allowed(name) {
286                    return false;
287                }
288                true
289            })
290            .collect();
291
292        let tools = {
293            let Ok(dyn_state) = dynamic_tools::global().lock() else {
294                tracing::warn!("dynamic_tools mutex poisoned in list_tools; returning unfiltered");
295                return Ok(ListToolsResult {
296                    tools,
297                    ..Default::default()
298                });
299            };
300            if dyn_state.supports_list_changed() {
301                tools
302                    .into_iter()
303                    .filter(|t| dyn_state.is_tool_active(t.name.as_ref()))
304                    .collect()
305            } else {
306                tools
307            }
308        };
309
310        let tools = {
311            let active = self.workflow.read().await.clone();
312            if let Some(run) = active {
313                if run.current == "done" || is_workflow_stale(&run) {
314                    let mut wf = self.workflow.write().await;
315                    *wf = None;
316                    let _ = crate::core::workflow::clear_active();
317                } else if let Some(state) = run.spec.state(&run.current) {
318                    if let Some(allowed) = &state.allowed_tools {
319                        let mut allow: std::collections::HashSet<&str> =
320                            allowed.iter().map(std::string::String::as_str).collect();
321                        for passthrough in WORKFLOW_PASSTHROUGH_TOOLS {
322                            allow.insert(passthrough);
323                        }
324                        return Ok(ListToolsResult {
325                            tools: tools
326                                .into_iter()
327                                .filter(|t| allow.contains(t.name.as_ref()))
328                                .collect(),
329                            ..Default::default()
330                        });
331                    }
332                }
333            }
334            tools
335        };
336
337        let tools = {
338            let cfg = crate::core::config::Config::load();
339            let level = crate::core::config::CompressionLevel::effective(&cfg);
340            let mode =
341                crate::core::terse::mcp_compress::DescriptionMode::from_compression_level(&level);
342            if mode == crate::core::terse::mcp_compress::DescriptionMode::Full {
343                tools
344            } else {
345                tools
346                    .into_iter()
347                    .map(|mut t| {
348                        let compressed = crate::core::terse::mcp_compress::compress_description(
349                            t.name.as_ref(),
350                            t.description.as_deref().unwrap_or(""),
351                            mode,
352                        );
353                        t.description = Some(compressed.into());
354                        t
355                    })
356                    .collect()
357            }
358        };
359
360        Ok(ListToolsResult {
361            tools,
362            ..Default::default()
363        })
364    }
365
366    async fn list_prompts(
367        &self,
368        _request: Option<PaginatedRequestParams>,
369        _context: RequestContext<RoleServer>,
370    ) -> Result<rmcp::model::ListPromptsResult, ErrorData> {
371        Ok(rmcp::model::ListPromptsResult::with_all_items(
372            prompts::list_prompts(),
373        ))
374    }
375
376    async fn get_prompt(
377        &self,
378        request: rmcp::model::GetPromptRequestParams,
379        _context: RequestContext<RoleServer>,
380    ) -> Result<rmcp::model::GetPromptResult, ErrorData> {
381        let ledger = self.ledger.read().await;
382        match prompts::get_prompt(&request, &ledger) {
383            Some(result) => Ok(result),
384            None => Err(ErrorData::invalid_params(
385                format!("Unknown prompt: {}", request.name),
386                None,
387            )),
388        }
389    }
390
391    async fn list_resources(
392        &self,
393        _request: Option<PaginatedRequestParams>,
394        _context: RequestContext<RoleServer>,
395    ) -> Result<rmcp::model::ListResourcesResult, rmcp::ErrorData> {
396        Ok(rmcp::model::ListResourcesResult::with_all_items(
397            resources::list_resources(),
398        ))
399    }
400
401    async fn read_resource(
402        &self,
403        request: rmcp::model::ReadResourceRequestParams,
404        _context: RequestContext<RoleServer>,
405    ) -> Result<rmcp::model::ReadResourceResult, rmcp::ErrorData> {
406        let ledger = self.ledger.read().await;
407        match resources::read_resource(&request.uri, &ledger) {
408            Some(contents) => Ok(rmcp::model::ReadResourceResult::new(contents)),
409            None => Err(rmcp::ErrorData::resource_not_found(
410                format!("Unknown resource: {}", request.uri),
411                None,
412            )),
413        }
414    }
415
416    async fn call_tool(
417        &self,
418        request: CallToolRequestParams,
419        context: RequestContext<RoleServer>,
420    ) -> Result<CallToolResult, ErrorData> {
421        use std::panic::AssertUnwindSafe;
422
423        let progress_token = request
424            .meta
425            .as_ref()
426            .and_then(rmcp::model::Meta::get_progress_token);
427        if let Some(ref token) = progress_token {
428            let sender =
429                crate::server::progress::ProgressSender::new(context.peer.clone(), token.clone());
430            *self
431                .progress_sender
432                .lock()
433                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(sender);
434        }
435
436        let tool_name_for_panic = request.name.as_ref().to_string();
437        let args_fp_for_panic = request
438            .arguments
439            .as_ref()
440            .map(|a| {
441                crate::core::loop_detection::LoopDetector::fingerprint(&serde_json::Value::Object(
442                    a.clone(),
443                ))
444            })
445            .unwrap_or_default();
446
447        let loop_detector = self.loop_detector.clone();
448
449        match AssertUnwindSafe(self.call_tool_guarded(request))
450            .catch_unwind()
451            .await
452        {
453            Ok(result) => result,
454            Err(panic_payload) => {
455                let detail = if let Some(s) = panic_payload.downcast_ref::<&str>() {
456                    (*s).to_string()
457                } else if let Some(s) = panic_payload.downcast_ref::<String>() {
458                    s.clone()
459                } else {
460                    "unknown".to_string()
461                };
462                tracing::error!("call_tool panicked: {detail}");
463
464                if let Ok(mut detector) =
465                    tokio::time::timeout(std::time::Duration::from_secs(1), loop_detector.write())
466                        .await
467                {
468                    detector.record_error_outcome(&tool_name_for_panic, &args_fp_for_panic);
469                }
470
471                Ok(CallToolResult::error(vec![Content::text(
472                    "ERROR: lean-ctx internal error. The MCP server is still running. \
473                     Please retry or use a different approach."
474                        .to_string(),
475                )]))
476            }
477        }
478    }
479
480    async fn on_roots_list_changed(
481        &self,
482        _context: rmcp::service::NotificationContext<RoleServer>,
483    ) {
484        tracing::info!("Received roots/list_changed — will re-resolve on next tool call");
485        self.roots_resolved
486            .store(false, std::sync::atomic::Ordering::Relaxed);
487    }
488}