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