Skip to main content

lean_ctx_server/server/
server_handler.rs

1// SPDX-License-Identifier: Apache-2.0
2//! `rmcp::ServerHandler` trait implementation for [`LeanCtxServer`].
3//!
4//! Split out of `server/mod.rs`; `use super::*` re-imports the parent module’s
5//! aliases and sibling submodules. Methods attach to `LeanCtxServer` regardless
6//! of which module the impl block lives in.
7
8#[allow(clippy::wildcard_imports)]
9use super::*;
10
11/// rmcp moves wire `_meta` into RequestContext before calling the handler.
12/// Retain the existing guarded dispatcher and progress-token readers as owners.
13fn restore_tool_meta(request: &mut CallToolRequestParams, transport_meta: &rmcp::model::Meta) {
14    if !transport_meta.is_empty() {
15        request
16            .meta
17            .get_or_insert_with(rmcp::model::Meta::new)
18            .extend(transport_meta.clone());
19    }
20}
21
22/// Builds the advertised MCP server capabilities.
23///
24/// `tools` is always enabled **and** always declares `listChanged`: lean-ctx
25/// emits `notifications/tools/list_changed` whenever a tool call mutates the
26/// dynamic tool set (see `dispatch::send_tools_list_changed`). The MCP spec only
27/// permits sending that notification when the matching capability was advertised
28/// — otherwise a strict client (e.g. Claude Code) treats it as a protocol
29/// violation and drops the entire tool set ("connected, but no tools"). The
30/// `resources`/`prompts` surfaces stay client-gated so we never advertise a
31/// surface the connected client cannot use.
32fn server_capabilities(resources: bool, prompts: bool) -> ServerCapabilities {
33    match (resources, prompts) {
34        (true, true) => ServerCapabilities::builder()
35            .enable_tools()
36            .enable_tool_list_changed()
37            .enable_resources()
38            .enable_resources_subscribe()
39            .enable_prompts()
40            .build(),
41        (true, false) => ServerCapabilities::builder()
42            .enable_tools()
43            .enable_tool_list_changed()
44            .enable_resources()
45            .enable_resources_subscribe()
46            .build(),
47        (false, true) => ServerCapabilities::builder()
48            .enable_tools()
49            .enable_tool_list_changed()
50            .enable_prompts()
51            .build(),
52        (false, false) => ServerCapabilities::builder()
53            .enable_tools()
54            .enable_tool_list_changed()
55            .build(),
56    }
57}
58
59fn suppress_initialize_instructions(client_id: &str) -> bool {
60    let cfg = lean_ctx::core::config::Config::load();
61
62    cfg.declines_rule_steering() || matches!(client_id, "antigravity" | "gemini-cli")
63}
64
65fn engine_server_info() -> Implementation {
66    Implementation::new("lean-ctx", env!("CARGO_PKG_VERSION"))
67        .with_title("LeanCTX")
68        .with_description("Context Gateway for AI agents: task-relevant context, supported controls, local knowledge and observable evidence.")
69        .with_website_url("https://leanctx.com")
70}
71
72impl ServerHandler for LeanCtxServer {
73    fn get_info(&self) -> ServerInfo {
74        let capabilities = server_capabilities(true, true);
75
76        let instructions = lean_ctx::instructions::build_instructions(CrpMode::effective());
77
78        InitializeResult::new(capabilities)
79            .with_server_info(engine_server_info())
80            .with_instructions(instructions)
81    }
82
83    async fn initialize(
84        &self,
85        request: InitializeRequestParams,
86        context: RequestContext<RoleServer>,
87    ) -> Result<InitializeResult, ErrorData> {
88        let name = request.client_info.name.clone();
89        tracing::info!("MCP client connected: {:?}", name);
90        *self.client_name.write().await = name.clone();
91        *self.peer.write().await = Some(context.peer.clone());
92        // Attribute this connection's bus presence to its host, recorded
93        // before any registration below reads it. A shared server serves
94        // several clients from one process, so it names no single host.
95        if self.session_mode != crate::tools::SessionMode::Shared {
96            lean_ctx::core::client_capabilities::record_handshake(
97                &name,
98                &request.client_info.version,
99            );
100        }
101        // Per-agent savings attribution: a personal server serves exactly one
102        // client, so its ledger events can carry that client's identity. The
103        // shared (multi-client) mode keeps the process-level fallback.
104        if self.session_mode != crate::tools::SessionMode::Shared {
105            let bus_id = self.presence_agent_id.read().await.clone();
106            lean_ctx::core::agent_identity::set_session_attribution(&name, bus_id.as_deref());
107        }
108
109        if self.session_mode != crate::tools::SessionMode::Shared {
110            if let Ok(data_dir) = lean_ctx::core::data_dir::lean_ctx_data_dir() {
111                let radar = data_dir.join("context_radar.jsonl");
112                if radar.exists() {
113                    let prev = data_dir.join("context_radar.prev.jsonl");
114                    let _ = std::fs::rename(&radar, &prev);
115                }
116            }
117        }
118
119        let has_roots = request.capabilities.roots.is_some();
120        self.has_client_roots
121            .store(has_roots, std::sync::atomic::Ordering::Relaxed);
122        if has_roots {
123            tracing::info!("Client supports MCP roots/list — will resolve on first tool call");
124        }
125
126        let explicit_env_root = roots::root_from_env();
127        let workspace_selection = explicit_env_root
128            .is_none()
129            .then(roots::workspace_selection_from_env)
130            .flatten();
131        let env_root = explicit_env_root.or_else(|| {
132            workspace_selection
133                .as_ref()
134                .map(|selection| selection.chosen_root.clone())
135        });
136        let derived_root = derive_project_root_from_cwd();
137        let effective_root = roots::session_root(env_root, derived_root);
138        // AP04-G7: the first of several workspace folders stays the choice
139        // (#699); recording it lets `ctx_session status` and `/api/scope`
140        // name it and how to change it. Only when it is the session's root.
141        if self.session_mode != crate::tools::SessionMode::Shared
142            && !lean_ctx::core::workspace_authority::is_configured()
143            && let Some(selection) = workspace_selection
144                .filter(|selection| effective_root.as_ref() == Some(&selection.chosen_root))
145        {
146            lean_ctx::core::root_selection::record(&selection);
147        }
148
149        let cwd_str = std::env::current_dir()
150            .ok()
151            .map(|p| p.to_string_lossy().to_string())
152            .unwrap_or_default();
153        // A shared session keeps the root it was created for: the serving
154        // process's own env and working directory say nothing about the
155        // client, and every client of that session would move with it.
156        let process_roots_apply = self.session_mode != crate::tools::SessionMode::Shared;
157        {
158            let mut session = self.session.write().await;
159            if process_roots_apply && !cwd_str.is_empty() {
160                session.shell_cwd = Some(cwd_str.clone());
161            }
162            if let Some(root) = effective_root.as_ref().filter(|_| process_roots_apply) {
163                session.project_root = Some(root.clone());
164                tracing::info!("Project root set to: {root}");
165                // Cursor multi-root: register sibling workspace folders as extra
166                // trusted roots so explicit cross-folder paths are not rejected
167                // by the path jail (#699).
168                for other in roots::workspace_roots_from_env() {
169                    if &other != root && !session.extra_roots.contains(&other) {
170                        session.extra_roots.push(other);
171                    }
172                }
173            } else if process_roots_apply && let Some(ref root) = session.project_root {
174                // A previously persisted session may carry a contaminated root
175                // (e.g. HOME from an older build or a client that reported HOME
176                // as its workspace). Drop it unless it is a real, safe project
177                // dir — otherwise PROJECT MEMORY leaks across projects.
178                let root_path = std::path::Path::new(root);
179                let root_has_marker = has_project_marker(root_path);
180                let root_str = root_path.to_string_lossy();
181                let root_suspicious = lean_ctx::core::pathutil::is_broad_or_unsafe_root(root_path)
182                    || root_str.contains("/var/folders/")
183                    || root_str.contains("/tmp/")
184                    || root_str.contains("/.lmstudio")
185                    || root_str.contains("\\AppData\\Local\\Temp")
186                    || root_str.contains("\\Temp\\")
187                    || root_str.contains("\\.lmstudio");
188                if root_suspicious && !root_has_marker {
189                    tracing::info!("Dropping suspicious persisted project root: {root}");
190                    session.project_root = None;
191                }
192            }
193            let cfg_extra = lean_ctx::core::config::Config::load().extra_roots;
194            if !cfg_extra.is_empty() {
195                let existing: std::collections::HashSet<_> =
196                    session.extra_roots.iter().cloned().collect();
197                for r in cfg_extra {
198                    if !existing.contains(&r) {
199                        session.extra_roots.push(r);
200                    }
201                }
202            }
203            // A launcher-bound Workspace replaces every root found above
204            // (#2120): env/cwd roots, workspace folders and configured extra
205            // roots grant nothing; an unusable Workspace leaves no root.
206            match lean_ctx::core::workspace_authority::load_current() {
207                lean_ctx::core::workspace_authority::Authority::Legacy => {}
208                lean_ctx::core::workspace_authority::Authority::Workspace(snapshot) => {
209                    let primary = snapshot.primary.to_string_lossy().into_owned();
210                    tracing::info!(
211                        "Workspace {} rev {} bound: project root {primary}",
212                        snapshot.id,
213                        snapshot.revision
214                    );
215                    session.extra_roots = snapshot.additional_root_strings();
216                    session.shell_cwd = Some(primary.clone());
217                    session.project_root = Some(primary);
218                }
219                lean_ctx::core::workspace_authority::Authority::Unavailable(reason) => {
220                    tracing::error!(
221                        "{}",
222                        lean_ctx::core::workspace_authority::unavailable_reason(&reason)
223                    );
224                    session.extra_roots.clear();
225                    session.project_root = None;
226                    session.shell_cwd = None;
227                }
228            }
229            session.refresh_handoff_context();
230
231            if self.session_mode == crate::tools::SessionMode::Shared {
232                if let Some(ref root) = session.project_root
233                    && let Some(ref rt) = self.context_os
234                {
235                    rt.shared_sessions.persist_best_effort(
236                        root,
237                        &self.workspace_id,
238                        &self.channel_id,
239                        &session,
240                    );
241                    rt.metrics.record_session_persisted();
242                }
243            } else if let Err(e) = session.save() {
244                // Synchronous on purpose: this runs once per connection, before
245                // any tool can be called, and the session must be on disk before
246                // it is. The wait is bounded by `PreparedSave`'s lock deadline,
247                // so it cannot become the wedge #1783 describes.
248                tracing::warn!("lean-ctx: failed to persist session state: {e}");
249            }
250        }
251
252        // Indices are warmed lazily on first use of a tool that needs them
253        // (issue #152), not eagerly here — a session that only uses
254        // ctx_read/ctx_shell/ctx_tree must not pay a full graph + BM25 scan.
255        // See `index_orchestrator::ensure_warm_for_tool`, driven from dispatch.
256
257        let agent_name = name.clone();
258        // A shared session's clients register on its own root, not the
259        // serving process's (AP04-G5).
260        let agent_root = if process_roots_apply {
261            effective_root.clone().unwrap_or_default()
262        } else {
263            self.session
264                .read()
265                .await
266                .project_root
267                .clone()
268                .unwrap_or_default()
269        };
270        let agent_id_handle = self.agent_id.clone();
271        let presence_agent_id_handle = self.presence_agent_id.clone();
272        let presence_role_handle = self.presence_role.clone();
273        tokio::task::spawn_blocking(move || {
274            if std::env::var("LEAN_CTX_HEADLESS").is_ok() {
275                return;
276            }
277
278            // The startup presence predates the handshake; name its host now
279            // rather than after the maintenance below (or never, without a root).
280            if let Err(error) =
281                lean_ctx::core::agents::AgentRegistry::record_transport_client_persistent(
282                    &lean_ctx::core::agents::TransportClient::current(),
283                )
284            {
285                tracing::warn!("lean-ctx: failed to attribute MCP presence to its host: {error}");
286            }
287
288            // Avoid startup stampedes when multiple agent sessions initialize at once.
289            // These are best-effort maintenance tasks; it's fine to skip if another
290            // lean-ctx instance is already doing them.
291            let maintenance = lean_ctx::core::startup_guard::try_acquire_lock(
292                "startup-maintenance",
293                std::time::Duration::from_secs(2),
294                std::time::Duration::from_mins(2),
295            );
296            if maintenance.is_some() {
297                if let Some(home) = dirs::home_dir() {
298                    startup_rule_maintenance_at_home(&home);
299                    // The on-demand SKILL.md belongs to the same steering surface
300                    // as the rules block: the session-start heal writes rules for
301                    // every detected client, so a fresh machine that never ran
302                    // `lean-ctx setup` otherwise carries a permanent doctor
303                    // warning ("SKILL.md not installed"). Idempotent + gated on
304                    // the same opt-outs as setup (rules_injection=off inside,
305                    // auto_inject_skills=Some(false) here).
306                    if lean_ctx::core::config::Config::load()
307                        .setup
308                        .should_inject_skills()
309                    {
310                        let _ = lean_ctx::rules_inject::install_all_skills(&home);
311                    }
312                }
313                lean_ctx_hooks::hooks::refresh_installed_hooks();
314                lean_ctx::shell_hook::refresh_installed_shell_hooks();
315                lean_ctx::core::version_check::check_background();
316                // Enforce the on-disk budget: prune accumulated quarantined BM25
317                // indexes and cap the archive FTS DB (#2364). Silent (tracing
318                // only) so it never corrupts the MCP stdio protocol.
319                let _ = lean_ctx::core::storage_maintenance::run_quiet();
320            }
321            drop(maintenance);
322
323            if !agent_root.is_empty() {
324                let (effective_role, role_source) = resolve_session_role(
325                    &agent_name,
326                    lean_ctx::core::agents::declared_session_role(),
327                );
328
329                // The active role is process-wide: a client of a shared server
330                // must not set it for every other client. Its own role is
331                // still remembered for its presence below.
332                if process_roots_apply {
333                    let _ =
334                        lean_ctx::core::roles::set_active_role_with_source(&effective_role, true);
335                }
336                // #1766: remember the resolved role so the fail-closed presence
337                // retry re-registers with it, not the `context-engine` placeholder.
338                if let Ok(mut guard) = presence_role_handle.try_write() {
339                    *guard = Some(effective_role.clone());
340                }
341
342                let registration = lean_ctx::core::agents::AgentRegistry::register_mcp_process_as(
343                    &agent_root,
344                    &effective_role,
345                    role_source,
346                    None,
347                );
348                let id = match registration {
349                    Ok(id) => Some(id),
350                    Err(error) => {
351                        tracing::warn!("lean-ctx: agent registration failed: {error}");
352                        None
353                    }
354                };
355                if let (Some(id), Ok(mut guard)) = (id.as_ref(), agent_id_handle.try_write()) {
356                    *guard = Some(id.clone());
357                }
358                if let (Some(id), Ok(mut guard)) = (id, presence_agent_id_handle.try_write()) {
359                    *guard = Some(id);
360                }
361            }
362        });
363
364        let client_caps = lean_ctx::core::client_capabilities::ClientMcpCapabilities::detect(&name);
365        tracing::info!("Client capabilities: {}", client_caps.format_summary());
366
367        {
368            let cfg = lean_ctx::core::config::Config::load();
369            let cats = cfg.default_tool_categories_effective();
370            dynamic_tools::init_from_config(&cats);
371        }
372
373        if let Some(max) = client_caps.max_tools
374            && let Ok(mut dt) = dynamic_tools::global().lock()
375        {
376            dt.set_supports_list_changed(true);
377            if max < 100 {
378                dt.unload_category(dynamic_tools::ToolCategory::Debug);
379                dt.unload_category(dynamic_tools::ToolCategory::Memory);
380            }
381        } else if client_caps.dynamic_tools
382            && let Ok(mut dt) = dynamic_tools::global().lock()
383        {
384            dt.set_supports_list_changed(true);
385        }
386
387        lean_ctx::core::client_capabilities::set_detected(&client_caps);
388
389        let session = self.session.read().await.clone();
390
391        let capabilities = server_capabilities(client_caps.resources, client_caps.prompts);
392
393        let result = InitializeResult::new(capabilities).with_server_info(engine_server_info());
394
395        // GH #1447: Antigravity/Gemini CLI disconnect when the initialize
396        // response contains `instructions`. The MCP spec marks it optional,
397        // but these clients fail on any unexpected field in the JSON envelope.
398        //
399        // GH #1599/#1758: both `rules_injection = "off"` and an explicit
400        // `setup.auto_inject_rules = false` decline lean-ctx rule steering.
401        // The initialize `instructions` field is that same steering block
402        // delivered over a different channel, so the central policy predicate
403        // must govern this surface too.
404        if suppress_initialize_instructions(client_caps.client_id.as_str()) {
405            Ok(result)
406        } else {
407            let instructions = lean_ctx::instructions::build_instructions_with_client_and_session(
408                CrpMode::effective(),
409                &name,
410                &session,
411            );
412            Ok(result.with_instructions(instructions))
413        }
414    }
415
416    async fn list_tools(
417        &self,
418        _request: Option<PaginatedRequestParams>,
419        _context: RequestContext<RoleServer>,
420    ) -> Result<ListToolsResult, ErrorData> {
421        use crate::server::tool_visibility::CandidateSet;
422        // Panic guard (mirrors call_tool): a panic while filtering the registry /
423        // touching the dynamic-tools mutex must not kill the rmcp request task.
424        use std::panic::AssertUnwindSafe;
425        let computed = AssertUnwindSafe(async {
426            let cfg = lean_ctx::core::config::Config::load();
427            let disabled = cfg.disabled_tools_effective();
428            let raw_profile = cfg.tool_profile_effective();
429            let tool_profile = crate::server::tool_visibility::resolve_auto_profile(&raw_profile);
430            crate::server::tool_visibility::record_auto_turn();
431            // A profile is "explicit" when the user opted into one (config field,
432            // env var, or a custom tools list). Without an explicit choice we keep
433            // the token-lean lazy core set as the default. With one, the profile is
434            // authoritative and resolves against the full registry, so e.g.
435            // `standard` advertises its full balanced set instead of the accidental
436            // `core ∩ standard` intersection.
437            let explicit_profile = crate::server::tool_visibility::explicit_profile(&cfg);
438
439            let client = self.client_name.read().await.clone();
440            let hook_covered = is_client_hook_covered(&client);
441
442            let candidate = crate::server::tool_visibility::candidate_set(
443                &crate::server::tool_visibility::CandidateInputs {
444                    full_mode: lean_ctx::tool_defs::is_full_mode(),
445                    unified_env: std::env::var("LEAN_CTX_UNIFIED").is_ok(),
446                    explicit_profile,
447                    hook_covered,
448                },
449            );
450            let all_tools = match candidate {
451                CandidateSet::Full | CandidateSet::ProfileAuthoritative => {
452                    if let Some(ref reg) = self.registry {
453                        reg.tool_defs()
454                    } else {
455                        // Unreachable in production: every constructor sets a registry
456                        // (locked by `production_server_always_has_registry`). If it
457                        // ever fires, the advertised static defs can drift from what
458                        // dispatch (which needs the registry) can execute — make it loud.
459                        tracing::error!(
460                            "list_tools served WITHOUT a tool registry (full mode) — advertising \
461                             static granular defs that dispatch cannot run; tools may drift from handlers."
462                        );
463                        lean_ctx::tool_defs::granular_tool_defs()
464                    }
465                }
466                CandidateSet::Unified => lean_ctx::tool_defs::unified_tool_defs(),
467                // GH #1474: ShadowOnly is never returned anymore; treat as LazyCore.
468                CandidateSet::ShadowOnly |
469                CandidateSet::LazyCore => {
470                    if let Some(ref reg) = self.registry {
471                        let core_names = lean_ctx::tool_defs::core_tool_names();
472                        reg.tool_defs()
473                            .into_iter()
474                            .filter(|t| core_names.contains(&t.name.as_ref()))
475                            .collect()
476                    } else {
477                        // Unreachable in production (see above); loud if it ever fires.
478                        tracing::error!(
479                            "list_tools served WITHOUT a tool registry (lazy mode) — advertising \
480                             static lazy defs that dispatch cannot run; tools may drift from handlers."
481                        );
482                        lean_ctx::tool_defs::lazy_tool_defs()
483                    }
484                }
485            };
486            let quirks = crate::server::tool_visibility::ClientQuirks::resolve(&client, candidate);
487
488            let active_role = lean_ctx::core::roles::active_role();
489            let tools: Vec<_> = all_tools
490                .into_iter()
491                .filter(|t| {
492                    let name = t.name.as_ref();
493                    crate::server::tool_visibility::is_tool_visible(
494                        name,
495                        &tool_profile,
496                        &disabled,
497                        quirks,
498                        active_role.is_tool_allowed(name),
499                    )
500                })
501                .collect();
502
503            // Guarantee the universal invoker is advertised in non-full mode. Lazy
504            // and profile filtering hide most tools; without ctx_call a static-list
505            // client (one that only calls advertised tools) could not reach them.
506            // ctx_call enforces the same role/workflow gates on the inner tool.
507            let tools = {
508                use crate::server::tool_visibility::INVOKER;
509                let mut tools = tools;
510                let already = tools.iter().any(|t| t.name.as_ref() == INVOKER);
511                if crate::server::tool_visibility::needs_invoker(
512                    lean_ctx::tool_defs::is_full_mode(),
513                    already,
514                    active_role.is_tool_allowed(INVOKER),
515                    &disabled,
516                ) && let Some(def) = self.registry.as_ref().and_then(|reg| {
517                    reg.tool_defs()
518                        .into_iter()
519                        .find(|t| t.name.as_ref() == INVOKER)
520                }) {
521                    tools.push(def);
522                }
523                tools
524            };
525
526            let tools = {
527                let Ok(dyn_state) = dynamic_tools::global().lock() else {
528                    tracing::warn!(
529                        "dynamic_tools mutex poisoned in list_tools; returning unfiltered"
530                    );
531                    return Ok(ListToolsResult {
532                        tools,
533                        ..Default::default()
534                    });
535                };
536                // The lazy category gate (load tools on demand for dynamic_tools
537                // clients) only applies to the *default* lean-core surface. When the
538                // user opted into an explicit profile, that profile IS the
539                // authoritative surface — gating it by category would silently drop
540                // profile-enabled tools like Standard's ctx_architecture /
541                // ctx_semantic_search for Codex et al. (#358), so the advertised set
542                // would no longer match `lean-ctx tools show`.
543                if crate::server::tool_visibility::category_gate_applies(
544                    dyn_state.supports_list_changed(),
545                    explicit_profile,
546                ) {
547                    tools
548                        .into_iter()
549                        .filter(|t| dyn_state.is_tool_active(t.name.as_ref()))
550                        .collect()
551                } else {
552                    tools
553                }
554            };
555
556            let tools = {
557                let active = self.workflow.read().await.clone();
558                if let Some(run) = active {
559                    if run.current == "done" || is_workflow_stale(&run) {
560                        let mut wf = self.workflow.write().await;
561                        *wf = None;
562                        let _ = lean_ctx::core::workflow::clear_active();
563                    } else if let Some(state) = run.spec.state(&run.current)
564                        && state.allowed_tools.is_some()
565                    {
566                        return Ok(ListToolsResult {
567                            tools: tools
568                                .into_iter()
569                                .filter(|t| workflow_lists(&run, t.name.as_ref()))
570                                .collect(),
571                            ..Default::default()
572                        });
573                    }
574                }
575                tools
576            };
577
578            let tools = {
579                let cfg = lean_ctx::core::config::Config::load();
580                let level = lean_ctx::core::config::CompressionLevel::effective(&cfg);
581                let mode =
582                    lean_ctx::core::terse::mcp_compress::DescriptionMode::from_compression_level(
583                        &level,
584                    );
585                if mode == lean_ctx::core::terse::mcp_compress::DescriptionMode::Full {
586                    tools
587                } else {
588                    tools
589                        .into_iter()
590                        .map(|mut t| {
591                            let compressed = lean_ctx::core::terse::mcp_compress::compress_description(
592                                t.name.as_ref(),
593                                t.description.as_deref().unwrap_or(""),
594                                mode,
595                            );
596                            t.description = Some(compressed.into());
597                            t
598                        })
599                        .collect()
600                }
601            };
602
603
604            // R28: Kernel schema optimization — budget-aware description compression.
605            let tools = crate::server::schema_hook::optimize_tools(tools, &client);
606            // #1008: When ctx_patch is hidden for this client, scrub references
607            // from other tools' descriptions so the LLM never sees the name and
608            // won't attempt to call it. Replace with ctx_edit (visible alternative).
609            let tools = if quirks.hide_ctx_patch {
610                tools
611                    .into_iter()
612                    .map(|mut t| {
613                        if let Some(ref desc) = t.description
614                            && desc.contains("ctx_patch")
615                        {
616                            t.description =
617                                Some(desc.replace("ctx_patch", "ctx_edit").into());
618                        }
619                        t
620                    })
621                    .collect()
622            } else {
623                tools
624            };
625
626            // Plan mode: restrict advertised tools to the curated read-only set.
627            let tools = {
628                use crate::tools::InteractionMode;
629                let mode = InteractionMode::from_u8(
630                    self.interaction_mode
631                        .load(std::sync::atomic::Ordering::Relaxed),
632                );
633                if mode == InteractionMode::Plan {
634                    let plan_set =
635                        lean_ctx::core::editor_registry::plan_mode::plan_mode_tools();
636                    tools
637                        .into_iter()
638                        .filter(|t| {
639                            let n = t.name.as_ref();
640                            plan_set.contains(&n) || n == "ctx_call"
641                        })
642                        .collect()
643                } else {
644                    tools
645                }
646            };
647
648            Ok(ListToolsResult {
649                tools,
650                ..Default::default()
651            })
652        })
653        .catch_unwind()
654        .await;
655        computed.unwrap_or_else(|_| {
656            // A panic here must NOT leave the agent tool-less — that is
657            // indistinguishable from "MCP totally failed" and gives the user no
658            // recovery path. Fall back to the static lazy-core defs (a pure,
659            // panic-free function) so ctx_read/ctx_shell/ctx_call stay available
660            // even if the dynamic/registry path blew up.
661            tracing::error!(
662                "list_tools panicked; serving the static lazy-core tool set as a fallback"
663            );
664            Ok(ListToolsResult {
665                tools: lean_ctx::tool_defs::lazy_tool_defs(),
666                ..Default::default()
667            })
668        })
669    }
670
671    fn list_prompts(
672        &self,
673        _request: Option<PaginatedRequestParams>,
674        _context: RequestContext<RoleServer>,
675    ) -> impl Future<Output = Result<rmcp::model::ListPromptsResult, ErrorData>> {
676        std::future::ready(Ok(rmcp::model::ListPromptsResult::with_all_items(
677            prompts::list_prompts(),
678        )))
679    }
680
681    async fn get_prompt(
682        &self,
683        request: rmcp::model::GetPromptRequestParams,
684        _context: RequestContext<RoleServer>,
685    ) -> Result<rmcp::model::GetPromptResult, ErrorData> {
686        let ledger = self.ledger.read().await;
687        match prompts::get_prompt(&request, &ledger) {
688            Some(result) => Ok(result),
689            None => Err(ErrorData::invalid_params(
690                format!("Unknown prompt: {}", request.name),
691                None,
692            )),
693        }
694    }
695
696    fn list_resources(
697        &self,
698        _request: Option<PaginatedRequestParams>,
699        _context: RequestContext<RoleServer>,
700    ) -> impl Future<Output = Result<rmcp::model::ListResourcesResult, rmcp::ErrorData>> {
701        std::future::ready(Ok(rmcp::model::ListResourcesResult::with_all_items(
702            resources::list_resources(),
703        )))
704    }
705
706    async fn read_resource(
707        &self,
708        request: rmcp::model::ReadResourceRequestParams,
709        _context: RequestContext<RoleServer>,
710    ) -> Result<rmcp::model::ReadResourceResult, rmcp::ErrorData> {
711        let ledger = self.ledger.read().await;
712        if let Some(contents) = resources::read_resource(&request.uri, &ledger) {
713            return Ok(rmcp::model::ReadResourceResult::new(contents));
714        }
715
716        // GH #1418: agents sometimes call resources/read with file:// URIs
717        // instead of ctx_read — serve the file directly rather than erroring.
718        let session = self.session.read().await;
719        let root = session.project_root.as_deref();
720        match file_resource::read_file_resource(&request.uri, root, &session.extra_roots) {
721            Ok(contents) => Ok(rmcp::model::ReadResourceResult::new(contents)),
722            Err(file_resource::FileResourceError::NotAFilePath) => {
723                Err(rmcp::ErrorData::resource_not_found(
724                    resources::unknown_resource_message(&request.uri),
725                    None,
726                ))
727            }
728            Err(e) => Err(rmcp::ErrorData::resource_not_found(e.to_string(), None)),
729        }
730    }
731
732    async fn call_tool(
733        &self,
734        mut request: CallToolRequestParams,
735        context: RequestContext<RoleServer>,
736    ) -> Result<CallToolResult, ErrorData> {
737        use std::panic::AssertUnwindSafe;
738
739        // In flight until this call returns — the idle policy never releases
740        // or exits under a running call.
741        let _activity = crate::server::mcp_idle::begin_call();
742        restore_tool_meta(&mut request, &context.meta);
743
744        let progress_token = request
745            .meta
746            .as_ref()
747            .and_then(rmcp::model::Meta::get_progress_token);
748        if let Some(ref token) = progress_token {
749            let sender =
750                crate::server::progress::ProgressSender::new(context.peer.clone(), token.clone());
751            *self
752                .progress_sender
753                .lock()
754                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(sender);
755        }
756
757        let tool_name_for_panic = request.name.as_ref().to_string();
758        let args_fp_for_panic = request
759            .arguments
760            .as_ref()
761            .map(|a| {
762                lean_ctx::core::loop_detection::LoopDetector::fingerprint(
763                    &serde_json::Value::Object(a.clone()),
764                )
765            })
766            .unwrap_or_default();
767
768        let loop_detector = self.loop_detector.clone();
769        let ct = context.ct.clone();
770        // The host's own id of this call, if it names one: noted with the
771        // delivery so the proxy can tell which call these bytes answered.
772        let host_call_id = crate::server::call_identity::host_call_id(request.meta.as_ref());
773
774        // #1781: scope the request's cancellation token for the whole dispatch
775        // so a tool that waits on a child process can observe an abandoned call
776        // instead of holding its blocking-pool slot until its own soft cap.
777        let Ok(result) = AssertUnwindSafe(crate::server::tool_trait::REQUEST_CT.scope(
778            ct.clone(),
779            Box::pin(crate::server::call_identity::scoped(
780                host_call_id,
781                self.call_tool_guarded(request),
782            )),
783        ))
784        .catch_unwind()
785        .await
786        else {
787            // A panic message can quote the content it failed on (a slice
788            // boundary inside a string, a parse error): the log line names
789            // the event only (AP10-R4).
790            tracing::error!("call_tool panicked (tool_panic)");
791
792            if let Ok(mut detector) =
793                tokio::time::timeout(std::time::Duration::from_secs(1), loop_detector.write()).await
794            {
795                detector.record_error_outcome(&tool_name_for_panic, &args_fp_for_panic);
796            }
797
798            return Ok(CallToolResult::error(vec![ContentBlock::text(
799                "ERROR: lean-ctx internal error. The MCP server is still running. \
800                 Please retry or use a different approach."
801                    .to_string(),
802            )]));
803        };
804        // #1265: If the client cancelled this request while the tool was
805        // executing, drop the result instead of replying to a stale ID.
806        if ct.is_cancelled() {
807            tracing::warn!(
808                "tool '{tool_name_for_panic}' completed after client cancellation — dropping result"
809            );
810            return Err(ErrorData::internal_error(
811                "request was cancelled by client",
812                None,
813            ));
814        }
815        result
816    }
817
818    async fn on_roots_list_changed(
819        &self,
820        _context: rmcp::service::NotificationContext<RoleServer>,
821    ) {
822        tracing::info!("Received roots/list_changed — will re-resolve on next tool call");
823        self.roots_resolved
824            .store(false, std::sync::atomic::Ordering::Relaxed);
825        // Fresh client signal — restore the transient-failure retry budget.
826        self.roots_list_attempts
827            .store(0, std::sync::atomic::Ordering::Relaxed);
828    }
829}
830
831fn startup_rule_maintenance_at_home(home: &std::path::Path) {
832    let cfg = lean_ctx::core::config::Config::load();
833    if cfg.declines_rule_steering() {
834        return;
835    }
836    let _ = lean_ctx::rules_inject::inject_all_rules(home);
837}
838
839/// Whether the tool listing shows `tool` under the active `run` whose current
840/// state gates tools: exactly when the call gate (`workflow::tool_gate`) lets
841/// it through. For a real run that is its allowed tools plus the
842/// pass-through tools, as before. For the stand-in of a withheld workflow the
843/// pass-through tools are NOT added — every call to them is refused — so the
844/// listing is the workflow control tool and the wrappers that reach it.
845fn workflow_lists(run: &lean_ctx::core::workflow::WorkflowRun, tool: &str) -> bool {
846    lean_ctx::core::workflow::tool_gate(run, tool, WORKFLOW_PASSTHROUGH_TOOLS)
847        == lean_ctx::core::workflow::ToolGate::Pass
848}
849
850/// The session role and its provenance: a role stated through the
851/// environment is declared; the client-name heuristic and the `coder`
852/// fallback are assumptions and are reported as such.
853fn resolve_session_role(
854    client_name: &str,
855    declared: Option<String>,
856) -> (String, lean_ctx::core::agents::RoleSource) {
857    if let Some(role) = declared {
858        return (role, lean_ctx::core::agents::RoleSource::Declared);
859    }
860    let heuristic_role = match client_name.to_lowercase().as_str() {
861        n if n.contains("cursor") => "coder",
862        n if n.contains("claude") => "coder",
863        n if n.contains("codebuddy") => "coder",
864        n if n.contains("codex") => "coder",
865        n if n.contains("antigravity") || n.contains("gemini") => "coder",
866        n if n.contains("review") => "reviewer",
867        n if n.contains("test") => "debugger",
868        _ => "coder",
869    };
870    (
871        heuristic_role.to_string(),
872        lean_ctx::core::agents::RoleSource::Assumed,
873    )
874}
875
876#[cfg(test)]
877mod tests {
878    use super::*;
879
880    #[test]
881    fn session_role_reports_whether_it_was_assumed() {
882        use lean_ctx::core::agents::RoleSource;
883        assert_eq!(
884            resolve_session_role("claude-code", None),
885            ("coder".to_string(), RoleSource::Assumed)
886        );
887        assert_eq!(
888            resolve_session_role("unknown-host", None),
889            ("coder".to_string(), RoleSource::Assumed)
890        );
891        assert_eq!(
892            resolve_session_role("code-review-bot", None),
893            ("reviewer".to_string(), RoleSource::Assumed)
894        );
895        assert_eq!(
896            resolve_session_role("claude-code", Some("reviewer".to_string())),
897            ("reviewer".to_string(), RoleSource::Declared)
898        );
899    }
900
901    /// The listing under a workflow is what the call gate lets through: the
902    /// stand-in for a withheld workflow lists no pass-through tool, a real
903    /// run lists its allowed tools and the pass-through tools as before.
904    #[test]
905    fn the_workflow_listing_matches_the_call_gate() {
906        use lean_ctx::core::context_admission::stored::lineage_support::{
907            governed, project, scoped,
908        };
909        use lean_ctx::core::workflow::gate::test_support::review;
910        use lean_ctx::core::workflow::{is_withheld_sentinel, load_active, save_active};
911        let _data = governed();
912        let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
913        let (_directory, root) = project();
914        let (_other_directory, other) = project();
915        scoped(&root, || save_active(&review())).expect("saved in its project");
916        let closed = scoped(&other, load_active)
917            .expect("load")
918            .expect("a withheld workflow is still a workflow");
919        assert!(is_withheld_sentinel(&closed));
920        let listed = |run: &lean_ctx::core::workflow::WorkflowRun| -> Vec<&'static str> {
921            WORKFLOW_PASSTHROUGH_TOOLS
922                .iter()
923                .chain(["ctx_call", "ctx_overview", "ctx_shell"].iter())
924                .copied()
925                .filter(|tool| workflow_lists(run, tool))
926                .collect()
927        };
928        assert_eq!(
929            listed(&closed),
930            ["ctx", "ctx_workflow", "ctx_call"],
931            "no pass-through tool is listed under the stand-in"
932        );
933        for refused in ["ctx_read", "ctx_search", "ctx_session", "ctx_ledger"] {
934            assert!(WORKFLOW_PASSTHROUGH_TOOLS.contains(&refused));
935            assert!(!workflow_lists(&closed, refused), "{refused}");
936        }
937
938        let real = scoped(&root, load_active).expect("load").expect("active");
939        let mut expected = WORKFLOW_PASSTHROUGH_TOOLS.to_vec();
940        expected.push("ctx_overview");
941        assert_eq!(listed(&real), expected, "a real run lists as before");
942    }
943
944    #[test]
945    fn initialize_instructions_respect_explicit_auto_inject_false() {
946        let _guard = lean_ctx::core::data_dir::test_env_lock();
947        let tmp = tempfile::tempdir().expect("tempdir");
948        let config_dir = tmp.path().join("config");
949
950        std::fs::create_dir_all(&config_dir).unwrap();
951        std::fs::write(
952            config_dir.join("config.toml"),
953            "[setup]\nauto_inject_rules = false\n",
954        )
955        .unwrap();
956
957        let _config_guard = lean_ctx::test_env::EnvVarGuard::set(
958            "LEAN_CTX_CONFIG_DIR",
959            config_dir.to_string_lossy().as_ref(),
960        );
961        let _injection_guard =
962            lean_ctx::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "shared");
963
964        assert!(
965            suppress_initialize_instructions("cursor"),
966            "explicit auto_inject_rules=false declines rule steering and must suppress MCP initialize instructions"
967        );
968    }
969
970    #[test]
971    fn initialize_instructions_preserve_default_auto_behavior() {
972        let _guard = lean_ctx::core::data_dir::test_env_lock();
973        let tmp = tempfile::tempdir().expect("tempdir");
974        let config_dir = tmp.path().join("config");
975
976        std::fs::create_dir_all(&config_dir).unwrap();
977
978        let _config_guard = lean_ctx::test_env::EnvVarGuard::set(
979            "LEAN_CTX_CONFIG_DIR",
980            config_dir.to_string_lossy().as_ref(),
981        );
982        let _injection_guard =
983            lean_ctx::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "shared");
984
985        assert!(
986            !suppress_initialize_instructions("cursor"),
987            "default auto_inject_rules=None must preserve initialize instructions"
988        );
989    }
990
991    #[test]
992    fn initialize_instructions_preserve_explicit_true_behavior() {
993        let _guard = lean_ctx::core::data_dir::test_env_lock();
994        let tmp = tempfile::tempdir().expect("tempdir");
995        let config_dir = tmp.path().join("config");
996
997        std::fs::create_dir_all(&config_dir).unwrap();
998        std::fs::write(
999            config_dir.join("config.toml"),
1000            "[setup]\nauto_inject_rules = true\n",
1001        )
1002        .unwrap();
1003
1004        let _config_guard = lean_ctx::test_env::EnvVarGuard::set(
1005            "LEAN_CTX_CONFIG_DIR",
1006            config_dir.to_string_lossy().as_ref(),
1007        );
1008        let _injection_guard =
1009            lean_ctx::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "shared");
1010
1011        assert!(
1012            !suppress_initialize_instructions("cursor"),
1013            "explicit auto_inject_rules=true must preserve initialize instructions"
1014        );
1015    }
1016
1017    #[test]
1018    fn initialize_instructions_respect_rules_injection_off() {
1019        let _guard = lean_ctx::core::data_dir::test_env_lock();
1020        let tmp = tempfile::tempdir().expect("tempdir");
1021        let config_dir = tmp.path().join("config");
1022
1023        std::fs::create_dir_all(&config_dir).unwrap();
1024
1025        let _config_guard = lean_ctx::test_env::EnvVarGuard::set(
1026            "LEAN_CTX_CONFIG_DIR",
1027            config_dir.to_string_lossy().as_ref(),
1028        );
1029        let _injection_guard =
1030            lean_ctx::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "off");
1031
1032        assert!(
1033            suppress_initialize_instructions("cursor"),
1034            "rules_injection=off must continue suppressing initialize instructions"
1035        );
1036    }
1037
1038    #[test]
1039    fn initialize_instructions_keep_client_compatibility_exceptions() {
1040        let _guard = lean_ctx::core::data_dir::test_env_lock();
1041        let tmp = tempfile::tempdir().expect("tempdir");
1042        let config_dir = tmp.path().join("config");
1043
1044        std::fs::create_dir_all(&config_dir).unwrap();
1045
1046        let _config_guard = lean_ctx::test_env::EnvVarGuard::set(
1047            "LEAN_CTX_CONFIG_DIR",
1048            config_dir.to_string_lossy().as_ref(),
1049        );
1050        let _injection_guard =
1051            lean_ctx::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "shared");
1052
1053        assert!(suppress_initialize_instructions("antigravity"));
1054        assert!(suppress_initialize_instructions("gemini-cli"));
1055    }
1056    #[test]
1057    fn startup_maintenance_respects_explicit_auto_inject_false() {
1058        let _guard = lean_ctx::core::data_dir::test_env_lock();
1059        let tmp = tempfile::tempdir().expect("tempdir");
1060        let home = tmp.path().join("home");
1061        let config_dir = tmp.path().join("config");
1062
1063        std::fs::create_dir_all(home.join(".cursor")).unwrap();
1064        std::fs::create_dir_all(&config_dir).unwrap();
1065
1066        std::fs::write(
1067            home.join(".cursor/mcp.json"),
1068            r#"{"mcpServers":{"lean-ctx":{"command":"lean-ctx","args":["mcp"]}}}"#,
1069        )
1070        .unwrap();
1071
1072        std::fs::write(
1073            config_dir.join("config.toml"),
1074            "[setup]\nauto_inject_rules = false\n",
1075        )
1076        .unwrap();
1077
1078        let _config_guard = lean_ctx::test_env::EnvVarGuard::set(
1079            "LEAN_CTX_CONFIG_DIR",
1080            config_dir.to_string_lossy().as_ref(),
1081        );
1082        let _injection_guard =
1083            lean_ctx::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "shared");
1084        let _scope_guard = lean_ctx::test_env::EnvVarGuard::set("LEAN_CTX_RULES_SCOPE", "global");
1085
1086        startup_rule_maintenance_at_home(&home);
1087
1088        let rules = home.join(".cursor/rules/lean-ctx.mdc");
1089
1090        assert!(
1091            !rules.exists(),
1092            "startup maintenance must not write Cursor rules when setup.auto_inject_rules=false"
1093        );
1094    }
1095
1096    #[test]
1097    fn automatic_rule_maintenance_allows_default_startup_write() {
1098        let _guard = lean_ctx::core::data_dir::test_env_lock();
1099        let tmp = tempfile::tempdir().expect("tempdir");
1100        let home = tmp.path().join("home");
1101        let config_dir = tmp.path().join("config");
1102
1103        std::fs::create_dir_all(home.join(".cursor")).unwrap();
1104        std::fs::create_dir_all(&config_dir).unwrap();
1105
1106        std::fs::write(
1107            home.join(".cursor/mcp.json"),
1108            r#"{"mcpServers":{"lean-ctx":{"command":"lean-ctx","args":["mcp"]}}}"#,
1109        )
1110        .unwrap();
1111
1112        let _config_guard = lean_ctx::test_env::EnvVarGuard::set(
1113            "LEAN_CTX_CONFIG_DIR",
1114            config_dir.to_string_lossy().as_ref(),
1115        );
1116        let _injection_guard =
1117            lean_ctx::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "shared");
1118        let _scope_guard = lean_ctx::test_env::EnvVarGuard::set("LEAN_CTX_RULES_SCOPE", "global");
1119
1120        startup_rule_maintenance_at_home(&home);
1121
1122        assert!(
1123            home.join(".cursor/rules/lean-ctx.mdc").exists(),
1124            "default auto_inject_rules=None must preserve zero-config startup rule maintenance"
1125        );
1126    }
1127
1128    #[test]
1129    fn automatic_rule_maintenance_allows_explicit_true_startup_write() {
1130        let _guard = lean_ctx::core::data_dir::test_env_lock();
1131        let tmp = tempfile::tempdir().expect("tempdir");
1132        let home = tmp.path().join("home");
1133        let config_dir = tmp.path().join("config");
1134
1135        std::fs::create_dir_all(home.join(".cursor")).unwrap();
1136        std::fs::create_dir_all(&config_dir).unwrap();
1137
1138        std::fs::write(
1139            home.join(".cursor/mcp.json"),
1140            r#"{"mcpServers":{"lean-ctx":{"command":"lean-ctx","args":["mcp"]}}}"#,
1141        )
1142        .unwrap();
1143
1144        std::fs::write(
1145            config_dir.join("config.toml"),
1146            "[setup]\nauto_inject_rules = true\n",
1147        )
1148        .unwrap();
1149
1150        let _config_guard = lean_ctx::test_env::EnvVarGuard::set(
1151            "LEAN_CTX_CONFIG_DIR",
1152            config_dir.to_string_lossy().as_ref(),
1153        );
1154        let _injection_guard =
1155            lean_ctx::test_env::EnvVarGuard::set("LEAN_CTX_RULES_INJECTION", "shared");
1156        let _scope_guard = lean_ctx::test_env::EnvVarGuard::set("LEAN_CTX_RULES_SCOPE", "global");
1157
1158        startup_rule_maintenance_at_home(&home);
1159
1160        assert!(
1161            home.join(".cursor/rules/lean-ctx.mdc").exists(),
1162            "explicit auto_inject_rules=true must preserve startup rule maintenance"
1163        );
1164    }
1165
1166    #[test]
1167    fn host_task_scope_retains_wire_metadata_for_receipt_replay() {
1168        use rmcp::model::{CallToolRequest, Meta};
1169        let mut wire: CallToolRequest = serde_json::from_value(serde_json::json!({
1170            "method":"tools/call", "params":{"name":"ctx_read", "arguments":{},
1171                "_meta":{"requestId":"wire-retry", "progressToken":19}}
1172        }))
1173        .unwrap();
1174        assert!(wire.params.meta.is_none());
1175        let transported = wire.extensions.get::<Meta>().unwrap().clone();
1176        restore_tool_meta(&mut wire.params, &transported);
1177        assert_eq!(
1178            wire.params.meta.as_ref().unwrap().0["requestId"],
1179            "wire-retry"
1180        );
1181        assert_eq!(wire.params.meta.as_ref().unwrap().0["progressToken"], 19);
1182        assert!(
1183            wire.params
1184                .meta
1185                .as_ref()
1186                .unwrap()
1187                .get_progress_token()
1188                .is_some()
1189        );
1190        wire.params
1191            .meta
1192            .as_mut()
1193            .unwrap()
1194            .0
1195            .insert("requestId".into(), "local-conflict".into());
1196        wire.params
1197            .meta
1198            .as_mut()
1199            .unwrap()
1200            .0
1201            .insert("local-only".into(), true.into());
1202        restore_tool_meta(&mut wire.params, &transported);
1203        assert_eq!(
1204            wire.params.meta.as_ref().unwrap().0["requestId"],
1205            "wire-retry"
1206        );
1207        assert_eq!(wire.params.meta.as_ref().unwrap().0["local-only"], true);
1208        let before = wire.params.meta.clone();
1209        restore_tool_meta(&mut wire.params, &Meta::new());
1210        assert_eq!(wire.params.meta, before);
1211    }
1212
1213    /// lean-ctx emits `notifications/tools/list_changed` whenever a tool call
1214    /// mutates the dynamic tool set. The capability MUST be advertised on every
1215    /// client surface (resources/prompts on or off) — otherwise a strict client
1216    /// such as Claude Code rejects the undeclared notification and drops the whole
1217    /// tool set ("connected, but tools not registered"). Regression guard for #688.
1218    #[test]
1219    fn server_capabilities_always_declare_tool_list_changed() {
1220        for (resources, prompts) in [(true, true), (true, false), (false, true), (false, false)] {
1221            let caps = server_capabilities(resources, prompts);
1222            let tools = caps.tools.expect("tools capability must be advertised");
1223            assert_eq!(
1224                tools.list_changed,
1225                Some(true),
1226                "listChanged must be Some(true) for (resources={resources}, prompts={prompts})"
1227            );
1228        }
1229    }
1230
1231    /// The `list_tools` panic guard serves `lazy_tool_defs()`; it must contain the
1232    /// essentials so an internal panic never leaves the agent tool-less (which is
1233    /// indistinguishable from "MCP totally failed"). Regression guard for #688.
1234    #[test]
1235    fn lazy_core_fallback_is_never_empty() {
1236        crate::server::registry::register_tool_catalog();
1237        let _guard = lean_ctx::core::data_dir::isolated_data_dir();
1238        let defs = lean_ctx::tool_defs::lazy_tool_defs();
1239        assert!(!defs.is_empty(), "lazy-core fallback must not be empty");
1240        for essential in ["ctx_read", "ctx_shell", "ctx_call"] {
1241            assert!(
1242                defs.iter().any(|t| t.name.as_ref() == essential),
1243                "lazy-core fallback must include {essential}"
1244            );
1245        }
1246    }
1247}