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
10/// Builds the advertised MCP server capabilities.
11///
12/// `tools` is always enabled **and** always declares `listChanged`: lean-ctx
13/// emits `notifications/tools/list_changed` whenever a tool call mutates the
14/// dynamic tool set (see `dispatch::send_tools_list_changed`). The MCP spec only
15/// permits sending that notification when the matching capability was advertised
16/// — otherwise a strict client (e.g. Claude Code) treats it as a protocol
17/// violation and drops the entire tool set ("connected, but no tools"). The
18/// `resources`/`prompts` surfaces stay client-gated so we never advertise a
19/// surface the connected client cannot use.
20fn server_capabilities(resources: bool, prompts: bool) -> ServerCapabilities {
21    match (resources, prompts) {
22        (true, true) => ServerCapabilities::builder()
23            .enable_tools()
24            .enable_tool_list_changed()
25            .enable_resources()
26            .enable_resources_subscribe()
27            .enable_prompts()
28            .build(),
29        (true, false) => ServerCapabilities::builder()
30            .enable_tools()
31            .enable_tool_list_changed()
32            .enable_resources()
33            .enable_resources_subscribe()
34            .build(),
35        (false, true) => ServerCapabilities::builder()
36            .enable_tools()
37            .enable_tool_list_changed()
38            .enable_prompts()
39            .build(),
40        (false, false) => ServerCapabilities::builder()
41            .enable_tools()
42            .enable_tool_list_changed()
43            .build(),
44    }
45}
46
47impl ServerHandler for LeanCtxServer {
48    fn get_info(&self) -> ServerInfo {
49        let capabilities = server_capabilities(true, true);
50
51        let config = crate::core::config::Config::load();
52        let level = crate::core::config::CompressionLevel::effective(&config);
53        let _ = crate::core::terse::rules_inject::inject(&level);
54
55        let instructions = crate::instructions::build_instructions(CrpMode::effective());
56
57        InitializeResult::new(capabilities)
58            .with_server_info(Implementation::new("lean-ctx", env!("CARGO_PKG_VERSION")))
59            .with_instructions(instructions)
60    }
61
62    async fn initialize(
63        &self,
64        request: InitializeRequestParams,
65        context: RequestContext<RoleServer>,
66    ) -> Result<InitializeResult, ErrorData> {
67        let name = request.client_info.name.clone();
68        tracing::info!("MCP client connected: {:?}", name);
69        *self.client_name.write().await = name.clone();
70        *self.peer.write().await = Some(context.peer.clone());
71
72        if self.session_mode != crate::tools::SessionMode::Shared {
73            crate::core::budget_tracker::BudgetTracker::global().reset();
74            if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
75                let radar = data_dir.join("context_radar.jsonl");
76                if radar.exists() {
77                    let prev = data_dir.join("context_radar.prev.jsonl");
78                    let _ = std::fs::rename(&radar, &prev);
79                }
80            }
81        }
82
83        let has_roots = request.capabilities.roots.is_some();
84        self.has_client_roots
85            .store(has_roots, std::sync::atomic::Ordering::Relaxed);
86        if has_roots {
87            tracing::info!("Client supports MCP roots/list — will resolve on first tool call");
88        }
89
90        let env_root = roots::root_from_env().or_else(roots::root_from_workspace_env);
91        let derived_root = derive_project_root_from_cwd();
92        let effective_root = env_root.or(derived_root);
93
94        let cwd_str = std::env::current_dir()
95            .ok()
96            .map(|p| p.to_string_lossy().to_string())
97            .unwrap_or_default();
98        {
99            let mut session = self.session.write().await;
100            if !cwd_str.is_empty() {
101                session.shell_cwd = Some(cwd_str.clone());
102            }
103            if let Some(ref root) = effective_root {
104                session.project_root = Some(root.clone());
105                tracing::info!("Project root set to: {root}");
106                // Cursor multi-root: register sibling workspace folders as extra
107                // trusted roots so explicit cross-folder paths are not rejected
108                // by the path jail (#699).
109                for other in roots::workspace_roots_from_env() {
110                    if &other != root && !session.extra_roots.contains(&other) {
111                        session.extra_roots.push(other);
112                    }
113                }
114            } else if let Some(ref root) = session.project_root {
115                // A previously persisted session may carry a contaminated root
116                // (e.g. HOME from an older build or a client that reported HOME
117                // as its workspace). Drop it unless it is a real, safe project
118                // dir — otherwise PROJECT MEMORY leaks across projects.
119                let root_path = std::path::Path::new(root);
120                let root_has_marker = has_project_marker(root_path);
121                let root_str = root_path.to_string_lossy();
122                let root_suspicious = crate::core::pathutil::is_broad_or_unsafe_root(root_path)
123                    || root_str.contains("/var/folders/")
124                    || root_str.contains("/tmp/")
125                    || root_str.contains("\\AppData\\Local\\Temp")
126                    || root_str.contains("\\Temp\\");
127                if root_suspicious && !root_has_marker {
128                    tracing::info!("Dropping suspicious persisted project root: {root}");
129                    session.project_root = None;
130                }
131            }
132            let cfg_extra = crate::core::config::Config::load().extra_roots;
133            if !cfg_extra.is_empty() {
134                let existing: std::collections::HashSet<_> =
135                    session.extra_roots.iter().cloned().collect();
136                for r in cfg_extra {
137                    if !existing.contains(&r) {
138                        session.extra_roots.push(r);
139                    }
140                }
141            }
142            if self.session_mode == crate::tools::SessionMode::Shared {
143                if let Some(ref root) = session.project_root
144                    && let Some(ref rt) = self.context_os
145                {
146                    rt.shared_sessions.persist_best_effort(
147                        root,
148                        &self.workspace_id,
149                        &self.channel_id,
150                        &session,
151                    );
152                    rt.metrics.record_session_persisted();
153                }
154            } else if let Err(e) = session.save() {
155                tracing::warn!("lean-ctx: failed to persist session state: {e}");
156            }
157        }
158
159        // Indices are warmed lazily on first use of a tool that needs them
160        // (issue #152), not eagerly here — a session that only uses
161        // ctx_read/ctx_shell/ctx_tree must not pay a full graph + BM25 scan.
162        // See `index_orchestrator::ensure_warm_for_tool`, driven from dispatch.
163
164        let agent_name = name.clone();
165        let agent_root = effective_root.clone().unwrap_or_default();
166        let agent_id_handle = self.agent_id.clone();
167        tokio::task::spawn_blocking(move || {
168            if std::env::var("LEAN_CTX_HEADLESS").is_ok() {
169                return;
170            }
171
172            // Avoid startup stampedes when multiple agent sessions initialize at once.
173            // These are best-effort maintenance tasks; it's fine to skip if another
174            // lean-ctx instance is already doing them.
175            let maintenance = crate::core::startup_guard::try_acquire_lock(
176                "startup-maintenance",
177                std::time::Duration::from_secs(2),
178                std::time::Duration::from_mins(2),
179            );
180            if maintenance.is_some() {
181                if let Some(home) = dirs::home_dir() {
182                    let _ = crate::rules_inject::inject_all_rules(&home);
183                }
184                crate::hooks::refresh_installed_hooks();
185                crate::core::version_check::check_background();
186                // Enforce the on-disk budget: prune accumulated quarantined BM25
187                // indexes and cap the archive FTS DB (#2364). Silent (tracing
188                // only) so it never corrupts the MCP stdio protocol.
189                let _ = crate::core::storage_maintenance::run_quiet();
190            }
191            drop(maintenance);
192
193            if !agent_root.is_empty() {
194                let heuristic_role = match agent_name.to_lowercase().as_str() {
195                    n if n.contains("cursor") => Some("coder"),
196                    n if n.contains("claude") => Some("coder"),
197                    n if n.contains("codebuddy") => Some("coder"),
198                    n if n.contains("codex") => Some("coder"),
199                    n if n.contains("antigravity") || n.contains("gemini") => Some("coder"),
200                    n if n.contains("review") => Some("reviewer"),
201                    n if n.contains("test") => Some("debugger"),
202                    _ => None,
203                };
204                let env_role = std::env::var("LEAN_CTX_ROLE")
205                    .or_else(|_| std::env::var("LEAN_CTX_AGENT_ROLE"))
206                    .ok();
207                let effective_role = env_role.as_deref().or(heuristic_role).unwrap_or("coder");
208
209                let _ = crate::core::roles::set_active_role_with_source(effective_role, true);
210
211                let mut registry = crate::core::agents::AgentRegistry::load_or_create();
212                registry.cleanup_stale(24);
213                let id = registry.register("mcp", Some(effective_role), &agent_root);
214                let _ = registry.save();
215                if let Ok(mut guard) = agent_id_handle.try_write() {
216                    *guard = Some(id);
217                }
218            }
219        });
220
221        let client_caps = crate::core::client_capabilities::ClientMcpCapabilities::detect(&name);
222        tracing::info!("Client capabilities: {}", client_caps.format_summary());
223
224        {
225            let cfg = crate::core::config::Config::load();
226            let cats = cfg.default_tool_categories_effective();
227            dynamic_tools::init_from_config(&cats);
228        }
229
230        if client_caps.dynamic_tools
231            && let Ok(mut dt) = dynamic_tools::global().lock()
232        {
233            dt.set_supports_list_changed(true);
234        }
235        if let Some(max) = client_caps.max_tools
236            && let Ok(mut dt) = dynamic_tools::global().lock()
237        {
238            dt.set_supports_list_changed(true);
239            if max < 100 {
240                dt.unload_category(dynamic_tools::ToolCategory::Debug);
241                dt.unload_category(dynamic_tools::ToolCategory::Memory);
242            }
243        }
244
245        crate::core::client_capabilities::set_detected(&client_caps);
246
247        let instructions =
248            crate::instructions::build_instructions_with_client(CrpMode::effective(), &name);
249
250        let capabilities = server_capabilities(client_caps.resources, client_caps.prompts);
251
252        Ok(InitializeResult::new(capabilities)
253            .with_server_info(Implementation::new("lean-ctx", env!("CARGO_PKG_VERSION")))
254            .with_instructions(instructions))
255    }
256
257    async fn list_tools(
258        &self,
259        _request: Option<PaginatedRequestParams>,
260        _context: RequestContext<RoleServer>,
261    ) -> Result<ListToolsResult, ErrorData> {
262        // Panic guard (mirrors call_tool): a panic while filtering the registry /
263        // touching the dynamic-tools mutex must not kill the rmcp request task.
264        use std::panic::AssertUnwindSafe;
265        let computed = AssertUnwindSafe(async {
266            let cfg = crate::core::config::Config::load();
267            let disabled = cfg.disabled_tools_effective();
268            let tool_profile = cfg.tool_profile_effective();
269            // A profile is "explicit" when the user opted into one (config field,
270            // env var, or a custom tools list). Without an explicit choice we keep
271            // the token-lean lazy core set as the default. With one, the profile is
272            // authoritative and resolves against the full registry, so e.g.
273            // `standard` advertises its full balanced set instead of the accidental
274            // `core ∩ standard` intersection.
275            let explicit_profile = crate::server::tool_visibility::explicit_profile(&cfg);
276
277            use crate::server::tool_visibility::CandidateSet;
278            let candidate = crate::server::tool_visibility::candidate_set(
279                crate::tool_defs::is_full_mode(),
280                std::env::var("LEAN_CTX_UNIFIED").is_ok(),
281                explicit_profile,
282            );
283            let all_tools = match candidate {
284                CandidateSet::Full | CandidateSet::ProfileAuthoritative => {
285                    if let Some(ref reg) = self.registry {
286                        reg.tool_defs()
287                    } else {
288                        // Unreachable in production: every constructor sets a registry
289                        // (locked by `production_server_always_has_registry`). If it
290                        // ever fires, the advertised static defs can drift from what
291                        // dispatch (which needs the registry) can execute — make it loud.
292                        tracing::error!(
293                            "list_tools served WITHOUT a tool registry (full mode) — advertising \
294                             static granular defs that dispatch cannot run; tools may drift from handlers."
295                        );
296                        crate::tool_defs::granular_tool_defs()
297                    }
298                }
299                CandidateSet::Unified => crate::tool_defs::unified_tool_defs(),
300                CandidateSet::LazyCore => {
301                    if let Some(ref reg) = self.registry {
302                        let core_names = crate::tool_defs::core_tool_names();
303                        reg.tool_defs()
304                            .into_iter()
305                            .filter(|t| core_names.contains(&t.name.as_ref()))
306                            .collect()
307                    } else {
308                        // Unreachable in production (see above); loud if it ever fires.
309                        tracing::error!(
310                            "list_tools served WITHOUT a tool registry (lazy mode) — advertising \
311                             static lazy defs that dispatch cannot run; tools may drift from handlers."
312                        );
313                        crate::tool_defs::lazy_tool_defs()
314                    }
315                }
316            };
317            let client = self.client_name.read().await.clone();
318            let is_zed = !client.is_empty() && client.to_lowercase().contains("zed");
319
320            let active_role = crate::core::roles::active_role();
321            let tools: Vec<_> = all_tools
322                .into_iter()
323                .filter(|t| {
324                    let name = t.name.as_ref();
325                    crate::server::tool_visibility::is_tool_visible(
326                        name,
327                        &tool_profile,
328                        &disabled,
329                        is_zed,
330                        active_role.is_tool_allowed(name),
331                    )
332                })
333                .collect();
334
335            // Guarantee the universal invoker is advertised in non-full mode. Lazy
336            // and profile filtering hide most tools; without ctx_call a static-list
337            // client (one that only calls advertised tools) could not reach them.
338            // ctx_call enforces the same role/workflow gates on the inner tool.
339            let tools = {
340                let mut tools = tools;
341                use crate::server::tool_visibility::INVOKER;
342                let already = tools.iter().any(|t| t.name.as_ref() == INVOKER);
343                if crate::server::tool_visibility::needs_invoker(
344                    crate::tool_defs::is_full_mode(),
345                    already,
346                    active_role.is_tool_allowed(INVOKER),
347                    &disabled,
348                ) && let Some(def) = self.registry.as_ref().and_then(|reg| {
349                    reg.tool_defs()
350                        .into_iter()
351                        .find(|t| t.name.as_ref() == INVOKER)
352                }) {
353                    tools.push(def);
354                }
355                tools
356            };
357
358            let tools = {
359                let Ok(dyn_state) = dynamic_tools::global().lock() else {
360                    tracing::warn!(
361                        "dynamic_tools mutex poisoned in list_tools; returning unfiltered"
362                    );
363                    return Ok(ListToolsResult {
364                        tools,
365                        ..Default::default()
366                    });
367                };
368                // The lazy category gate (load tools on demand for dynamic_tools
369                // clients) only applies to the *default* lean-core surface. When the
370                // user opted into an explicit profile, that profile IS the
371                // authoritative surface — gating it by category would silently drop
372                // profile-enabled tools like Standard's ctx_architecture /
373                // ctx_semantic_search for Codex et al. (#358), so the advertised set
374                // would no longer match `lean-ctx tools show`.
375                if crate::server::tool_visibility::category_gate_applies(
376                    dyn_state.supports_list_changed(),
377                    explicit_profile,
378                ) {
379                    tools
380                        .into_iter()
381                        .filter(|t| dyn_state.is_tool_active(t.name.as_ref()))
382                        .collect()
383                } else {
384                    tools
385                }
386            };
387
388            let tools = {
389                let active = self.workflow.read().await.clone();
390                if let Some(run) = active {
391                    if run.current == "done" || is_workflow_stale(&run) {
392                        let mut wf = self.workflow.write().await;
393                        *wf = None;
394                        let _ = crate::core::workflow::clear_active();
395                    } else if let Some(state) = run.spec.state(&run.current)
396                        && let Some(allowed) = &state.allowed_tools
397                    {
398                        let mut allow: std::collections::HashSet<&str> =
399                            allowed.iter().map(std::string::String::as_str).collect();
400                        for passthrough in WORKFLOW_PASSTHROUGH_TOOLS {
401                            allow.insert(passthrough);
402                        }
403                        return Ok(ListToolsResult {
404                            tools: tools
405                                .into_iter()
406                                .filter(|t| allow.contains(t.name.as_ref()))
407                                .collect(),
408                            ..Default::default()
409                        });
410                    }
411                }
412                tools
413            };
414
415            let tools = {
416                let cfg = crate::core::config::Config::load();
417                let level = crate::core::config::CompressionLevel::effective(&cfg);
418                let mode =
419                    crate::core::terse::mcp_compress::DescriptionMode::from_compression_level(
420                        &level,
421                    );
422                if mode == crate::core::terse::mcp_compress::DescriptionMode::Full {
423                    tools
424                } else {
425                    tools
426                        .into_iter()
427                        .map(|mut t| {
428                            let compressed = crate::core::terse::mcp_compress::compress_description(
429                                t.name.as_ref(),
430                                t.description.as_deref().unwrap_or(""),
431                                mode,
432                            );
433                            t.description = Some(compressed.into());
434                            t
435                        })
436                        .collect()
437                }
438            };
439
440            Ok(ListToolsResult {
441                tools,
442                ..Default::default()
443            })
444        })
445        .catch_unwind()
446        .await;
447        computed.unwrap_or_else(|_| {
448            // A panic here must NOT leave the agent tool-less — that is
449            // indistinguishable from "MCP totally failed" and gives the user no
450            // recovery path. Fall back to the static lazy-core defs (a pure,
451            // panic-free function) so ctx_read/ctx_shell/ctx_call stay available
452            // even if the dynamic/registry path blew up.
453            tracing::error!(
454                "list_tools panicked; serving the static lazy-core tool set as a fallback"
455            );
456            Ok(ListToolsResult {
457                tools: crate::tool_defs::lazy_tool_defs(),
458                ..Default::default()
459            })
460        })
461    }
462
463    async fn list_prompts(
464        &self,
465        _request: Option<PaginatedRequestParams>,
466        _context: RequestContext<RoleServer>,
467    ) -> Result<rmcp::model::ListPromptsResult, ErrorData> {
468        Ok(rmcp::model::ListPromptsResult::with_all_items(
469            prompts::list_prompts(),
470        ))
471    }
472
473    async fn get_prompt(
474        &self,
475        request: rmcp::model::GetPromptRequestParams,
476        _context: RequestContext<RoleServer>,
477    ) -> Result<rmcp::model::GetPromptResult, ErrorData> {
478        let ledger = self.ledger.read().await;
479        match prompts::get_prompt(&request, &ledger) {
480            Some(result) => Ok(result),
481            None => Err(ErrorData::invalid_params(
482                format!("Unknown prompt: {}", request.name),
483                None,
484            )),
485        }
486    }
487
488    async fn list_resources(
489        &self,
490        _request: Option<PaginatedRequestParams>,
491        _context: RequestContext<RoleServer>,
492    ) -> Result<rmcp::model::ListResourcesResult, rmcp::ErrorData> {
493        Ok(rmcp::model::ListResourcesResult::with_all_items(
494            resources::list_resources(),
495        ))
496    }
497
498    async fn read_resource(
499        &self,
500        request: rmcp::model::ReadResourceRequestParams,
501        _context: RequestContext<RoleServer>,
502    ) -> Result<rmcp::model::ReadResourceResult, rmcp::ErrorData> {
503        let ledger = self.ledger.read().await;
504        match resources::read_resource(&request.uri, &ledger) {
505            Some(contents) => Ok(rmcp::model::ReadResourceResult::new(contents)),
506            None => Err(rmcp::ErrorData::resource_not_found(
507                format!("Unknown resource: {}", request.uri),
508                None,
509            )),
510        }
511    }
512
513    async fn call_tool(
514        &self,
515        request: CallToolRequestParams,
516        context: RequestContext<RoleServer>,
517    ) -> Result<CallToolResult, ErrorData> {
518        use std::panic::AssertUnwindSafe;
519
520        let progress_token = request
521            .meta
522            .as_ref()
523            .and_then(rmcp::model::Meta::get_progress_token);
524        if let Some(ref token) = progress_token {
525            let sender =
526                crate::server::progress::ProgressSender::new(context.peer.clone(), token.clone());
527            *self
528                .progress_sender
529                .lock()
530                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(sender);
531        }
532
533        let tool_name_for_panic = request.name.as_ref().to_string();
534        let args_fp_for_panic = request
535            .arguments
536            .as_ref()
537            .map(|a| {
538                crate::core::loop_detection::LoopDetector::fingerprint(&serde_json::Value::Object(
539                    a.clone(),
540                ))
541            })
542            .unwrap_or_default();
543
544        let loop_detector = self.loop_detector.clone();
545
546        match AssertUnwindSafe(self.call_tool_guarded(request))
547            .catch_unwind()
548            .await
549        {
550            Ok(result) => result,
551            Err(panic_payload) => {
552                let detail = if let Some(s) = panic_payload.downcast_ref::<&str>() {
553                    (*s).to_string()
554                } else if let Some(s) = panic_payload.downcast_ref::<String>() {
555                    s.clone()
556                } else {
557                    "unknown".to_string()
558                };
559                tracing::error!("call_tool panicked: {detail}");
560
561                if let Ok(mut detector) =
562                    tokio::time::timeout(std::time::Duration::from_secs(1), loop_detector.write())
563                        .await
564                {
565                    detector.record_error_outcome(&tool_name_for_panic, &args_fp_for_panic);
566                }
567
568                Ok(CallToolResult::error(vec![Content::text(
569                    "ERROR: lean-ctx internal error. The MCP server is still running. \
570                     Please retry or use a different approach."
571                        .to_string(),
572                )]))
573            }
574        }
575    }
576
577    async fn on_roots_list_changed(
578        &self,
579        _context: rmcp::service::NotificationContext<RoleServer>,
580    ) {
581        tracing::info!("Received roots/list_changed — will re-resolve on next tool call");
582        self.roots_resolved
583            .store(false, std::sync::atomic::Ordering::Relaxed);
584    }
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590
591    /// lean-ctx emits `notifications/tools/list_changed` whenever a tool call
592    /// mutates the dynamic tool set. The capability MUST be advertised on every
593    /// client surface (resources/prompts on or off) — otherwise a strict client
594    /// such as Claude Code rejects the undeclared notification and drops the whole
595    /// tool set ("connected, but tools not registered"). Regression guard for #688.
596    #[test]
597    fn server_capabilities_always_declare_tool_list_changed() {
598        for (resources, prompts) in [(true, true), (true, false), (false, true), (false, false)] {
599            let caps = server_capabilities(resources, prompts);
600            let tools = caps.tools.expect("tools capability must be advertised");
601            assert_eq!(
602                tools.list_changed,
603                Some(true),
604                "listChanged must be Some(true) for (resources={resources}, prompts={prompts})"
605            );
606        }
607    }
608
609    /// The `list_tools` panic guard serves `lazy_tool_defs()`; it must contain the
610    /// essentials so an internal panic never leaves the agent tool-less (which is
611    /// indistinguishable from "MCP totally failed"). Regression guard for #688.
612    #[test]
613    fn lazy_core_fallback_is_never_empty() {
614        let _guard = crate::core::data_dir::isolated_data_dir();
615        let defs = crate::tool_defs::lazy_tool_defs();
616        assert!(!defs.is_empty(), "lazy-core fallback must not be empty");
617        for essential in ["ctx_read", "ctx_shell", "ctx_call"] {
618            assert!(
619                defs.iter().any(|t| t.name.as_ref() == essential),
620                "lazy-core fallback must include {essential}"
621            );
622        }
623    }
624}