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                    && let Some(ref rt) = self.context_os
105                {
106                    rt.shared_sessions.persist_best_effort(
107                        root,
108                        &self.workspace_id,
109                        &self.channel_id,
110                        &session,
111                    );
112                    rt.metrics.record_session_persisted();
113                }
114            } else if let Err(e) = session.save() {
115                tracing::warn!("lean-ctx: failed to persist session state: {e}");
116            }
117        }
118
119        // Indices are warmed lazily on first use of a tool that needs them
120        // (issue #152), not eagerly here — a session that only uses
121        // ctx_read/ctx_shell/ctx_tree must not pay a full graph + BM25 scan.
122        // See `index_orchestrator::ensure_warm_for_tool`, driven from dispatch.
123
124        let agent_name = name.clone();
125        let agent_root = effective_root.clone().unwrap_or_default();
126        let agent_id_handle = self.agent_id.clone();
127        tokio::task::spawn_blocking(move || {
128            if std::env::var("LEAN_CTX_HEADLESS").is_ok() {
129                return;
130            }
131
132            // Avoid startup stampedes when multiple agent sessions initialize at once.
133            // These are best-effort maintenance tasks; it's fine to skip if another
134            // lean-ctx instance is already doing them.
135            let maintenance = crate::core::startup_guard::try_acquire_lock(
136                "startup-maintenance",
137                std::time::Duration::from_secs(2),
138                std::time::Duration::from_mins(2),
139            );
140            if maintenance.is_some() {
141                if let Some(home) = dirs::home_dir() {
142                    let _ = crate::rules_inject::inject_all_rules(&home);
143                }
144                crate::hooks::refresh_installed_hooks();
145                crate::core::version_check::check_background();
146                // Enforce the on-disk budget: prune accumulated quarantined BM25
147                // indexes and cap the archive FTS DB (#2364). Silent (tracing
148                // only) so it never corrupts the MCP stdio protocol.
149                let _ = crate::core::storage_maintenance::run_quiet();
150            }
151            drop(maintenance);
152
153            if !agent_root.is_empty() {
154                let heuristic_role = match agent_name.to_lowercase().as_str() {
155                    n if n.contains("cursor") => Some("coder"),
156                    n if n.contains("claude") => Some("coder"),
157                    n if n.contains("codebuddy") => Some("coder"),
158                    n if n.contains("codex") => Some("coder"),
159                    n if n.contains("antigravity") || n.contains("gemini") => Some("coder"),
160                    n if n.contains("review") => Some("reviewer"),
161                    n if n.contains("test") => Some("debugger"),
162                    _ => None,
163                };
164                let env_role = std::env::var("LEAN_CTX_ROLE")
165                    .or_else(|_| std::env::var("LEAN_CTX_AGENT_ROLE"))
166                    .ok();
167                let effective_role = env_role.as_deref().or(heuristic_role).unwrap_or("coder");
168
169                let _ = crate::core::roles::set_active_role_with_source(effective_role, true);
170
171                let mut registry = crate::core::agents::AgentRegistry::load_or_create();
172                registry.cleanup_stale(24);
173                let id = registry.register("mcp", Some(effective_role), &agent_root);
174                let _ = registry.save();
175                if let Ok(mut guard) = agent_id_handle.try_write() {
176                    *guard = Some(id);
177                }
178            }
179        });
180
181        let client_caps = crate::core::client_capabilities::ClientMcpCapabilities::detect(&name);
182        tracing::info!("Client capabilities: {}", client_caps.format_summary());
183
184        {
185            let cfg = crate::core::config::Config::load();
186            let cats = cfg.default_tool_categories_effective();
187            dynamic_tools::init_from_config(&cats);
188        }
189
190        if client_caps.dynamic_tools
191            && let Ok(mut dt) = dynamic_tools::global().lock()
192        {
193            dt.set_supports_list_changed(true);
194        }
195        if let Some(max) = client_caps.max_tools
196            && let Ok(mut dt) = dynamic_tools::global().lock()
197        {
198            dt.set_supports_list_changed(true);
199            if max < 100 {
200                dt.unload_category(dynamic_tools::ToolCategory::Debug);
201                dt.unload_category(dynamic_tools::ToolCategory::Memory);
202            }
203        }
204
205        crate::core::client_capabilities::set_detected(&client_caps);
206
207        let instructions =
208            crate::instructions::build_instructions_with_client(CrpMode::effective(), &name);
209
210        let capabilities = match (client_caps.resources, client_caps.prompts) {
211            (true, true) => ServerCapabilities::builder()
212                .enable_tools()
213                .enable_resources()
214                .enable_resources_subscribe()
215                .enable_prompts()
216                .build(),
217            (true, false) => ServerCapabilities::builder()
218                .enable_tools()
219                .enable_resources()
220                .enable_resources_subscribe()
221                .build(),
222            (false, true) => ServerCapabilities::builder()
223                .enable_tools()
224                .enable_prompts()
225                .build(),
226            (false, false) => ServerCapabilities::builder().enable_tools().build(),
227        };
228
229        Ok(InitializeResult::new(capabilities)
230            .with_server_info(Implementation::new("lean-ctx", env!("CARGO_PKG_VERSION")))
231            .with_instructions(instructions))
232    }
233
234    async fn list_tools(
235        &self,
236        _request: Option<PaginatedRequestParams>,
237        _context: RequestContext<RoleServer>,
238    ) -> Result<ListToolsResult, ErrorData> {
239        // Panic guard (mirrors call_tool): a panic while filtering the registry /
240        // touching the dynamic-tools mutex must not kill the rmcp request task.
241        use std::panic::AssertUnwindSafe;
242        let computed = AssertUnwindSafe(async {
243            let cfg = crate::core::config::Config::load();
244            let disabled = cfg.disabled_tools_effective();
245            let tool_profile = cfg.tool_profile_effective();
246            // A profile is "explicit" when the user opted into one (config field,
247            // env var, or a custom tools list). Without an explicit choice we keep
248            // the token-lean lazy core set as the default. With one, the profile is
249            // authoritative and resolves against the full registry, so e.g.
250            // `standard` advertises its full balanced set instead of the accidental
251            // `core ∩ standard` intersection.
252            let explicit_profile = crate::server::tool_visibility::explicit_profile(&cfg);
253
254            use crate::server::tool_visibility::CandidateSet;
255            let candidate = crate::server::tool_visibility::candidate_set(
256                crate::tool_defs::is_full_mode(),
257                std::env::var("LEAN_CTX_UNIFIED").is_ok(),
258                explicit_profile,
259            );
260            let all_tools = match candidate {
261                CandidateSet::Full | CandidateSet::ProfileAuthoritative => {
262                    if let Some(ref reg) = self.registry {
263                        reg.tool_defs()
264                    } else {
265                        crate::tool_defs::granular_tool_defs()
266                    }
267                }
268                CandidateSet::Unified => crate::tool_defs::unified_tool_defs(),
269                CandidateSet::LazyCore => {
270                    if let Some(ref reg) = self.registry {
271                        let core_names = crate::tool_defs::core_tool_names();
272                        reg.tool_defs()
273                            .into_iter()
274                            .filter(|t| core_names.contains(&t.name.as_ref()))
275                            .collect()
276                    } else {
277                        crate::tool_defs::lazy_tool_defs()
278                    }
279                }
280            };
281            let client = self.client_name.read().await.clone();
282            let is_zed = !client.is_empty() && client.to_lowercase().contains("zed");
283
284            let active_role = crate::core::roles::active_role();
285            let tools: Vec<_> = all_tools
286                .into_iter()
287                .filter(|t| {
288                    let name = t.name.as_ref();
289                    crate::server::tool_visibility::is_tool_visible(
290                        name,
291                        &tool_profile,
292                        &disabled,
293                        is_zed,
294                        active_role.is_tool_allowed(name),
295                    )
296                })
297                .collect();
298
299            // Guarantee the universal invoker is advertised in non-full mode. Lazy
300            // and profile filtering hide most tools; without ctx_call a static-list
301            // client (one that only calls advertised tools) could not reach them.
302            // ctx_call enforces the same role/workflow gates on the inner tool.
303            let tools = {
304                let mut tools = tools;
305                use crate::server::tool_visibility::INVOKER;
306                let already = tools.iter().any(|t| t.name.as_ref() == INVOKER);
307                if crate::server::tool_visibility::needs_invoker(
308                    crate::tool_defs::is_full_mode(),
309                    already,
310                    active_role.is_tool_allowed(INVOKER),
311                    &disabled,
312                ) && let Some(def) = self.registry.as_ref().and_then(|reg| {
313                    reg.tool_defs()
314                        .into_iter()
315                        .find(|t| t.name.as_ref() == INVOKER)
316                }) {
317                    tools.push(def);
318                }
319                tools
320            };
321
322            let tools = {
323                let Ok(dyn_state) = dynamic_tools::global().lock() else {
324                    tracing::warn!(
325                        "dynamic_tools mutex poisoned in list_tools; returning unfiltered"
326                    );
327                    return Ok(ListToolsResult {
328                        tools,
329                        ..Default::default()
330                    });
331                };
332                // The lazy category gate (load tools on demand for dynamic_tools
333                // clients) only applies to the *default* lean-core surface. When the
334                // user opted into an explicit profile, that profile IS the
335                // authoritative surface — gating it by category would silently drop
336                // profile-enabled tools like Standard's ctx_architecture /
337                // ctx_semantic_search for Codex et al. (#358), so the advertised set
338                // would no longer match `lean-ctx tools show`.
339                if crate::server::tool_visibility::category_gate_applies(
340                    dyn_state.supports_list_changed(),
341                    explicit_profile,
342                ) {
343                    tools
344                        .into_iter()
345                        .filter(|t| dyn_state.is_tool_active(t.name.as_ref()))
346                        .collect()
347                } else {
348                    tools
349                }
350            };
351
352            let tools = {
353                let active = self.workflow.read().await.clone();
354                if let Some(run) = active {
355                    if run.current == "done" || is_workflow_stale(&run) {
356                        let mut wf = self.workflow.write().await;
357                        *wf = None;
358                        let _ = crate::core::workflow::clear_active();
359                    } else if let Some(state) = run.spec.state(&run.current)
360                        && let Some(allowed) = &state.allowed_tools
361                    {
362                        let mut allow: std::collections::HashSet<&str> =
363                            allowed.iter().map(std::string::String::as_str).collect();
364                        for passthrough in WORKFLOW_PASSTHROUGH_TOOLS {
365                            allow.insert(passthrough);
366                        }
367                        return Ok(ListToolsResult {
368                            tools: tools
369                                .into_iter()
370                                .filter(|t| allow.contains(t.name.as_ref()))
371                                .collect(),
372                            ..Default::default()
373                        });
374                    }
375                }
376                tools
377            };
378
379            let tools = {
380                let cfg = crate::core::config::Config::load();
381                let level = crate::core::config::CompressionLevel::effective(&cfg);
382                let mode =
383                    crate::core::terse::mcp_compress::DescriptionMode::from_compression_level(
384                        &level,
385                    );
386                if mode == crate::core::terse::mcp_compress::DescriptionMode::Full {
387                    tools
388                } else {
389                    tools
390                        .into_iter()
391                        .map(|mut t| {
392                            let compressed = crate::core::terse::mcp_compress::compress_description(
393                                t.name.as_ref(),
394                                t.description.as_deref().unwrap_or(""),
395                                mode,
396                            );
397                            t.description = Some(compressed.into());
398                            t
399                        })
400                        .collect()
401                }
402            };
403
404            Ok(ListToolsResult {
405                tools,
406                ..Default::default()
407            })
408        })
409        .catch_unwind()
410        .await;
411        computed.unwrap_or_else(|_| {
412            tracing::error!(
413                "list_tools panicked; returning an empty tool list to keep the MCP server alive"
414            );
415            Ok(ListToolsResult::default())
416        })
417    }
418
419    async fn list_prompts(
420        &self,
421        _request: Option<PaginatedRequestParams>,
422        _context: RequestContext<RoleServer>,
423    ) -> Result<rmcp::model::ListPromptsResult, ErrorData> {
424        Ok(rmcp::model::ListPromptsResult::with_all_items(
425            prompts::list_prompts(),
426        ))
427    }
428
429    async fn get_prompt(
430        &self,
431        request: rmcp::model::GetPromptRequestParams,
432        _context: RequestContext<RoleServer>,
433    ) -> Result<rmcp::model::GetPromptResult, ErrorData> {
434        let ledger = self.ledger.read().await;
435        match prompts::get_prompt(&request, &ledger) {
436            Some(result) => Ok(result),
437            None => Err(ErrorData::invalid_params(
438                format!("Unknown prompt: {}", request.name),
439                None,
440            )),
441        }
442    }
443
444    async fn list_resources(
445        &self,
446        _request: Option<PaginatedRequestParams>,
447        _context: RequestContext<RoleServer>,
448    ) -> Result<rmcp::model::ListResourcesResult, rmcp::ErrorData> {
449        Ok(rmcp::model::ListResourcesResult::with_all_items(
450            resources::list_resources(),
451        ))
452    }
453
454    async fn read_resource(
455        &self,
456        request: rmcp::model::ReadResourceRequestParams,
457        _context: RequestContext<RoleServer>,
458    ) -> Result<rmcp::model::ReadResourceResult, rmcp::ErrorData> {
459        let ledger = self.ledger.read().await;
460        match resources::read_resource(&request.uri, &ledger) {
461            Some(contents) => Ok(rmcp::model::ReadResourceResult::new(contents)),
462            None => Err(rmcp::ErrorData::resource_not_found(
463                format!("Unknown resource: {}", request.uri),
464                None,
465            )),
466        }
467    }
468
469    async fn call_tool(
470        &self,
471        request: CallToolRequestParams,
472        context: RequestContext<RoleServer>,
473    ) -> Result<CallToolResult, ErrorData> {
474        use std::panic::AssertUnwindSafe;
475
476        let progress_token = request
477            .meta
478            .as_ref()
479            .and_then(rmcp::model::Meta::get_progress_token);
480        if let Some(ref token) = progress_token {
481            let sender =
482                crate::server::progress::ProgressSender::new(context.peer.clone(), token.clone());
483            *self
484                .progress_sender
485                .lock()
486                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(sender);
487        }
488
489        let tool_name_for_panic = request.name.as_ref().to_string();
490        let args_fp_for_panic = request
491            .arguments
492            .as_ref()
493            .map(|a| {
494                crate::core::loop_detection::LoopDetector::fingerprint(&serde_json::Value::Object(
495                    a.clone(),
496                ))
497            })
498            .unwrap_or_default();
499
500        let loop_detector = self.loop_detector.clone();
501
502        match AssertUnwindSafe(self.call_tool_guarded(request))
503            .catch_unwind()
504            .await
505        {
506            Ok(result) => result,
507            Err(panic_payload) => {
508                let detail = if let Some(s) = panic_payload.downcast_ref::<&str>() {
509                    (*s).to_string()
510                } else if let Some(s) = panic_payload.downcast_ref::<String>() {
511                    s.clone()
512                } else {
513                    "unknown".to_string()
514                };
515                tracing::error!("call_tool panicked: {detail}");
516
517                if let Ok(mut detector) =
518                    tokio::time::timeout(std::time::Duration::from_secs(1), loop_detector.write())
519                        .await
520                {
521                    detector.record_error_outcome(&tool_name_for_panic, &args_fp_for_panic);
522                }
523
524                Ok(CallToolResult::error(vec![Content::text(
525                    "ERROR: lean-ctx internal error. The MCP server is still running. \
526                     Please retry or use a different approach."
527                        .to_string(),
528                )]))
529            }
530        }
531    }
532
533    async fn on_roots_list_changed(
534        &self,
535        _context: rmcp::service::NotificationContext<RoleServer>,
536    ) {
537        tracing::info!("Received roots/list_changed — will re-resolve on next tool call");
538        self.roots_resolved
539            .store(false, std::sync::atomic::Ordering::Relaxed);
540    }
541}