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