1#[allow(clippy::wildcard_imports)]
8use super::*;
9
10fn server_capabilities(resources: bool, prompts: bool) -> ServerCapabilities {
21 match (resources, prompts) {
22 (true, true) => ServerCapabilities::builder()
23 .enable_tools()
24 .enable_tool_list_changed()
25 .enable_resources()
26 .enable_resources_subscribe()
27 .enable_prompts()
28 .build(),
29 (true, false) => ServerCapabilities::builder()
30 .enable_tools()
31 .enable_tool_list_changed()
32 .enable_resources()
33 .enable_resources_subscribe()
34 .build(),
35 (false, true) => ServerCapabilities::builder()
36 .enable_tools()
37 .enable_tool_list_changed()
38 .enable_prompts()
39 .build(),
40 (false, false) => ServerCapabilities::builder()
41 .enable_tools()
42 .enable_tool_list_changed()
43 .build(),
44 }
45}
46
47impl ServerHandler for LeanCtxServer {
48 fn get_info(&self) -> ServerInfo {
49 let capabilities = server_capabilities(true, true);
50
51 let instructions = crate::instructions::build_instructions(CrpMode::effective());
52
53 InitializeResult::new(capabilities)
54 .with_server_info(Implementation::new("lean-ctx", env!("CARGO_PKG_VERSION")))
55 .with_instructions(instructions)
56 }
57
58 async fn initialize(
59 &self,
60 request: InitializeRequestParams,
61 context: RequestContext<RoleServer>,
62 ) -> Result<InitializeResult, ErrorData> {
63 let name = request.client_info.name.clone();
64 tracing::info!("MCP client connected: {:?}", name);
65 *self.client_name.write().await = name.clone();
66 *self.peer.write().await = Some(context.peer.clone());
67
68 if self.session_mode != crate::tools::SessionMode::Shared {
69 crate::core::budget_tracker::BudgetTracker::global().reset();
70 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
71 let radar = data_dir.join("context_radar.jsonl");
72 if radar.exists() {
73 let prev = data_dir.join("context_radar.prev.jsonl");
74 let _ = std::fs::rename(&radar, &prev);
75 }
76 }
77 }
78
79 let has_roots = request.capabilities.roots.is_some();
80 self.has_client_roots
81 .store(has_roots, std::sync::atomic::Ordering::Relaxed);
82 if has_roots {
83 tracing::info!("Client supports MCP roots/list — will resolve on first tool call");
84 }
85
86 let env_root = roots::root_from_env().or_else(roots::root_from_workspace_env);
87 let derived_root = derive_project_root_from_cwd();
88 let effective_root = env_root.or(derived_root);
89
90 let cwd_str = std::env::current_dir()
91 .ok()
92 .map(|p| p.to_string_lossy().to_string())
93 .unwrap_or_default();
94 {
95 let mut session = self.session.write().await;
96 if !cwd_str.is_empty() {
97 session.shell_cwd = Some(cwd_str.clone());
98 }
99 if let Some(ref root) = effective_root {
100 session.project_root = Some(root.clone());
101 tracing::info!("Project root set to: {root}");
102 for other in roots::workspace_roots_from_env() {
106 if &other != root && !session.extra_roots.contains(&other) {
107 session.extra_roots.push(other);
108 }
109 }
110 } else if let Some(ref root) = session.project_root {
111 let root_path = std::path::Path::new(root);
116 let root_has_marker = has_project_marker(root_path);
117 let root_str = root_path.to_string_lossy();
118 let root_suspicious = crate::core::pathutil::is_broad_or_unsafe_root(root_path)
119 || root_str.contains("/var/folders/")
120 || root_str.contains("/tmp/")
121 || root_str.contains("\\AppData\\Local\\Temp")
122 || root_str.contains("\\Temp\\");
123 if root_suspicious && !root_has_marker {
124 tracing::info!("Dropping suspicious persisted project root: {root}");
125 session.project_root = None;
126 }
127 }
128 let cfg_extra = crate::core::config::Config::load().extra_roots;
129 if !cfg_extra.is_empty() {
130 let existing: std::collections::HashSet<_> =
131 session.extra_roots.iter().cloned().collect();
132 for r in cfg_extra {
133 if !existing.contains(&r) {
134 session.extra_roots.push(r);
135 }
136 }
137 }
138 if self.session_mode == crate::tools::SessionMode::Shared {
139 if let Some(ref root) = session.project_root
140 && let Some(ref rt) = self.context_os
141 {
142 rt.shared_sessions.persist_best_effort(
143 root,
144 &self.workspace_id,
145 &self.channel_id,
146 &session,
147 );
148 rt.metrics.record_session_persisted();
149 }
150 } else if let Err(e) = session.save() {
151 tracing::warn!("lean-ctx: failed to persist session state: {e}");
152 }
153 }
154
155 let agent_name = name.clone();
161 let agent_root = effective_root.clone().unwrap_or_default();
162 let agent_id_handle = self.agent_id.clone();
163 tokio::task::spawn_blocking(move || {
164 if std::env::var("LEAN_CTX_HEADLESS").is_ok() {
165 return;
166 }
167
168 let maintenance = crate::core::startup_guard::try_acquire_lock(
172 "startup-maintenance",
173 std::time::Duration::from_secs(2),
174 std::time::Duration::from_mins(2),
175 );
176 if maintenance.is_some() {
177 if let Some(home) = dirs::home_dir() {
178 let _ = crate::rules_inject::inject_all_rules(&home);
179 }
180 crate::hooks::refresh_installed_hooks();
181 crate::core::version_check::check_background();
182 let _ = crate::core::storage_maintenance::run_quiet();
186 }
187 drop(maintenance);
188
189 if !agent_root.is_empty() {
190 let heuristic_role = match agent_name.to_lowercase().as_str() {
191 n if n.contains("cursor") => Some("coder"),
192 n if n.contains("claude") => Some("coder"),
193 n if n.contains("codebuddy") => Some("coder"),
194 n if n.contains("codex") => Some("coder"),
195 n if n.contains("antigravity") || n.contains("gemini") => Some("coder"),
196 n if n.contains("review") => Some("reviewer"),
197 n if n.contains("test") => Some("debugger"),
198 _ => None,
199 };
200 let env_role = std::env::var("LEAN_CTX_ROLE")
201 .or_else(|_| std::env::var("LEAN_CTX_AGENT_ROLE"))
202 .ok();
203 let effective_role = env_role.as_deref().or(heuristic_role).unwrap_or("coder");
204
205 let _ = crate::core::roles::set_active_role_with_source(effective_role, true);
206
207 let mut registry = crate::core::agents::AgentRegistry::load_or_create();
208 registry.cleanup_stale(24);
209 let id = registry.register("mcp", Some(effective_role), &agent_root);
210 let _ = registry.save();
211 if let Ok(mut guard) = agent_id_handle.try_write() {
212 *guard = Some(id);
213 }
214 }
215 });
216
217 let client_caps = crate::core::client_capabilities::ClientMcpCapabilities::detect(&name);
218 tracing::info!("Client capabilities: {}", client_caps.format_summary());
219
220 {
221 let cfg = crate::core::config::Config::load();
222 let cats = cfg.default_tool_categories_effective();
223 dynamic_tools::init_from_config(&cats);
224 }
225
226 if let Some(max) = client_caps.max_tools
227 && let Ok(mut dt) = dynamic_tools::global().lock()
228 {
229 dt.set_supports_list_changed(true);
230 if max < 100 {
231 dt.unload_category(dynamic_tools::ToolCategory::Debug);
232 dt.unload_category(dynamic_tools::ToolCategory::Memory);
233 }
234 } else if client_caps.dynamic_tools
235 && let Ok(mut dt) = dynamic_tools::global().lock()
236 {
237 dt.set_supports_list_changed(true);
238 }
239
240 crate::core::client_capabilities::set_detected(&client_caps);
241
242 let instructions =
243 crate::instructions::build_instructions_with_client(CrpMode::effective(), &name);
244
245 let capabilities = server_capabilities(client_caps.resources, client_caps.prompts);
246
247 Ok(InitializeResult::new(capabilities)
248 .with_server_info(Implementation::new("lean-ctx", env!("CARGO_PKG_VERSION")))
249 .with_instructions(instructions))
250 }
251
252 async fn list_tools(
253 &self,
254 _request: Option<PaginatedRequestParams>,
255 _context: RequestContext<RoleServer>,
256 ) -> Result<ListToolsResult, ErrorData> {
257 use crate::server::tool_visibility::CandidateSet;
258 use std::panic::AssertUnwindSafe;
261 let computed = AssertUnwindSafe(async {
262 let cfg = crate::core::config::Config::load();
263 let disabled = cfg.disabled_tools_effective();
264 let tool_profile = cfg.tool_profile_effective();
265 let explicit_profile = crate::server::tool_visibility::explicit_profile(&cfg);
272
273 let candidate = crate::server::tool_visibility::candidate_set(
274 crate::tool_defs::is_full_mode(),
275 std::env::var("LEAN_CTX_UNIFIED").is_ok(),
276 explicit_profile,
277 );
278 let all_tools = match candidate {
279 CandidateSet::Full | CandidateSet::ProfileAuthoritative => {
280 if let Some(ref reg) = self.registry {
281 reg.tool_defs()
282 } else {
283 tracing::error!(
288 "list_tools served WITHOUT a tool registry (full mode) — advertising \
289 static granular defs that dispatch cannot run; tools may drift from handlers."
290 );
291 crate::tool_defs::granular_tool_defs()
292 }
293 }
294 CandidateSet::Unified => crate::tool_defs::unified_tool_defs(),
295 CandidateSet::LazyCore => {
296 if let Some(ref reg) = self.registry {
297 let core_names = crate::tool_defs::core_tool_names();
298 reg.tool_defs()
299 .into_iter()
300 .filter(|t| core_names.contains(&t.name.as_ref()))
301 .collect()
302 } else {
303 tracing::error!(
305 "list_tools served WITHOUT a tool registry (lazy mode) — advertising \
306 static lazy defs that dispatch cannot run; tools may drift from handlers."
307 );
308 crate::tool_defs::lazy_tool_defs()
309 }
310 }
311 };
312 let client = self.client_name.read().await.clone();
313 let is_zed = !client.is_empty() && client.to_lowercase().contains("zed");
314
315 let active_role = crate::core::roles::active_role();
316 let tools: Vec<_> = all_tools
317 .into_iter()
318 .filter(|t| {
319 let name = t.name.as_ref();
320 crate::server::tool_visibility::is_tool_visible(
321 name,
322 &tool_profile,
323 &disabled,
324 is_zed,
325 active_role.is_tool_allowed(name),
326 )
327 })
328 .collect();
329
330 let tools = {
335 use crate::server::tool_visibility::INVOKER;
336 let mut tools = tools;
337 let already = tools.iter().any(|t| t.name.as_ref() == INVOKER);
338 if crate::server::tool_visibility::needs_invoker(
339 crate::tool_defs::is_full_mode(),
340 already,
341 active_role.is_tool_allowed(INVOKER),
342 &disabled,
343 ) && let Some(def) = self.registry.as_ref().and_then(|reg| {
344 reg.tool_defs()
345 .into_iter()
346 .find(|t| t.name.as_ref() == INVOKER)
347 }) {
348 tools.push(def);
349 }
350 tools
351 };
352
353 let tools = {
354 let Ok(dyn_state) = dynamic_tools::global().lock() else {
355 tracing::warn!(
356 "dynamic_tools mutex poisoned in list_tools; returning unfiltered"
357 );
358 return Ok(ListToolsResult {
359 tools,
360 ..Default::default()
361 });
362 };
363 if crate::server::tool_visibility::category_gate_applies(
371 dyn_state.supports_list_changed(),
372 explicit_profile,
373 ) {
374 tools
375 .into_iter()
376 .filter(|t| dyn_state.is_tool_active(t.name.as_ref()))
377 .collect()
378 } else {
379 tools
380 }
381 };
382
383 let tools = {
384 let active = self.workflow.read().await.clone();
385 if let Some(run) = active {
386 if run.current == "done" || is_workflow_stale(&run) {
387 let mut wf = self.workflow.write().await;
388 *wf = None;
389 let _ = crate::core::workflow::clear_active();
390 } else if let Some(state) = run.spec.state(&run.current)
391 && let Some(allowed) = &state.allowed_tools
392 {
393 let mut allow: std::collections::HashSet<&str> =
394 allowed.iter().map(std::string::String::as_str).collect();
395 for passthrough in WORKFLOW_PASSTHROUGH_TOOLS {
396 allow.insert(passthrough);
397 }
398 return Ok(ListToolsResult {
399 tools: tools
400 .into_iter()
401 .filter(|t| allow.contains(t.name.as_ref()))
402 .collect(),
403 ..Default::default()
404 });
405 }
406 }
407 tools
408 };
409
410 let tools = {
411 let cfg = crate::core::config::Config::load();
412 let level = crate::core::config::CompressionLevel::effective(&cfg);
413 let mode =
414 crate::core::terse::mcp_compress::DescriptionMode::from_compression_level(
415 &level,
416 );
417 if mode == crate::core::terse::mcp_compress::DescriptionMode::Full {
418 tools
419 } else {
420 tools
421 .into_iter()
422 .map(|mut t| {
423 let compressed = crate::core::terse::mcp_compress::compress_description(
424 t.name.as_ref(),
425 t.description.as_deref().unwrap_or(""),
426 mode,
427 );
428 t.description = Some(compressed.into());
429 t
430 })
431 .collect()
432 }
433 };
434
435 Ok(ListToolsResult {
436 tools,
437 ..Default::default()
438 })
439 })
440 .catch_unwind()
441 .await;
442 computed.unwrap_or_else(|_| {
443 tracing::error!(
449 "list_tools panicked; serving the static lazy-core tool set as a fallback"
450 );
451 Ok(ListToolsResult {
452 tools: crate::tool_defs::lazy_tool_defs(),
453 ..Default::default()
454 })
455 })
456 }
457
458 fn list_prompts(
459 &self,
460 _request: Option<PaginatedRequestParams>,
461 _context: RequestContext<RoleServer>,
462 ) -> impl Future<Output = Result<rmcp::model::ListPromptsResult, ErrorData>> {
463 std::future::ready(Ok(rmcp::model::ListPromptsResult::with_all_items(
464 prompts::list_prompts(),
465 )))
466 }
467
468 async fn get_prompt(
469 &self,
470 request: rmcp::model::GetPromptRequestParams,
471 _context: RequestContext<RoleServer>,
472 ) -> Result<rmcp::model::GetPromptResult, ErrorData> {
473 let ledger = self.ledger.read().await;
474 match prompts::get_prompt(&request, &ledger) {
475 Some(result) => Ok(result),
476 None => Err(ErrorData::invalid_params(
477 format!("Unknown prompt: {}", request.name),
478 None,
479 )),
480 }
481 }
482
483 fn list_resources(
484 &self,
485 _request: Option<PaginatedRequestParams>,
486 _context: RequestContext<RoleServer>,
487 ) -> impl Future<Output = Result<rmcp::model::ListResourcesResult, rmcp::ErrorData>> {
488 std::future::ready(Ok(rmcp::model::ListResourcesResult::with_all_items(
489 resources::list_resources(),
490 )))
491 }
492
493 async fn read_resource(
494 &self,
495 request: rmcp::model::ReadResourceRequestParams,
496 _context: RequestContext<RoleServer>,
497 ) -> Result<rmcp::model::ReadResourceResult, rmcp::ErrorData> {
498 let ledger = self.ledger.read().await;
499 match resources::read_resource(&request.uri, &ledger) {
500 Some(contents) => Ok(rmcp::model::ReadResourceResult::new(contents)),
501 None => Err(rmcp::ErrorData::resource_not_found(
502 format!("Unknown resource: {}", request.uri),
503 None,
504 )),
505 }
506 }
507
508 async fn call_tool(
509 &self,
510 request: CallToolRequestParams,
511 context: RequestContext<RoleServer>,
512 ) -> Result<CallToolResult, ErrorData> {
513 use std::panic::AssertUnwindSafe;
514
515 let progress_token = request
516 .meta
517 .as_ref()
518 .and_then(rmcp::model::Meta::get_progress_token);
519 if let Some(ref token) = progress_token {
520 let sender =
521 crate::server::progress::ProgressSender::new(context.peer.clone(), token.clone());
522 *self
523 .progress_sender
524 .lock()
525 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(sender);
526 }
527
528 let tool_name_for_panic = request.name.as_ref().to_string();
529 let args_fp_for_panic = request
530 .arguments
531 .as_ref()
532 .map(|a| {
533 crate::core::loop_detection::LoopDetector::fingerprint(&serde_json::Value::Object(
534 a.clone(),
535 ))
536 })
537 .unwrap_or_default();
538
539 let loop_detector = self.loop_detector.clone();
540
541 match AssertUnwindSafe(self.call_tool_guarded(request))
542 .catch_unwind()
543 .await
544 {
545 Ok(result) => result,
546 Err(panic_payload) => {
547 let detail = if let Some(s) = panic_payload.downcast_ref::<&str>() {
548 (*s).to_string()
549 } else if let Some(s) = panic_payload.downcast_ref::<String>() {
550 s.clone()
551 } else {
552 "unknown".to_string()
553 };
554 tracing::error!("call_tool panicked: {detail}");
555
556 if let Ok(mut detector) =
557 tokio::time::timeout(std::time::Duration::from_secs(1), loop_detector.write())
558 .await
559 {
560 detector.record_error_outcome(&tool_name_for_panic, &args_fp_for_panic);
561 }
562
563 Ok(CallToolResult::error(vec![Content::text(
564 "ERROR: lean-ctx internal error. The MCP server is still running. \
565 Please retry or use a different approach."
566 .to_string(),
567 )]))
568 }
569 }
570 }
571
572 async fn on_roots_list_changed(
573 &self,
574 _context: rmcp::service::NotificationContext<RoleServer>,
575 ) {
576 tracing::info!("Received roots/list_changed — will re-resolve on next tool call");
577 self.roots_resolved
578 .store(false, std::sync::atomic::Ordering::Relaxed);
579 }
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585
586 #[test]
592 fn server_capabilities_always_declare_tool_list_changed() {
593 for (resources, prompts) in [(true, true), (true, false), (false, true), (false, false)] {
594 let caps = server_capabilities(resources, prompts);
595 let tools = caps.tools.expect("tools capability must be advertised");
596 assert_eq!(
597 tools.list_changed,
598 Some(true),
599 "listChanged must be Some(true) for (resources={resources}, prompts={prompts})"
600 );
601 }
602 }
603
604 #[test]
608 fn lazy_core_fallback_is_never_empty() {
609 let _guard = crate::core::data_dir::isolated_data_dir();
610 let defs = crate::tool_defs::lazy_tool_defs();
611 assert!(!defs.is_empty(), "lazy-core fallback must not be empty");
612 for essential in ["ctx_read", "ctx_shell", "ctx_call"] {
613 assert!(
614 defs.iter().any(|t| t.name.as_ref() == essential),
615 "lazy-core fallback must include {essential}"
616 );
617 }
618 }
619}