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