1use super::super::{
4 CallToolRequestParams, CallToolResult, ErrorData, LeanCtxServer, elicitation, helpers,
5 permission_inheritance,
6};
7use lean_ctx::core::ocla::response_cache::{
8 CachedResponse, ResponseCache, ResponseCacheKey, global_response_cache,
9};
10use rmcp::model::{ContentBlock, Meta};
11use serde::Deserialize;
12use serde_json::{Map, Value};
13use std::{
14 sync::Arc,
15 time::{Duration, Instant},
16};
17
18mod kernel;
19pub(super) mod replay_lineage;
20
21#[cfg(test)]
22mod task_lineage_tests;
23
24const CACHEABLE_TOOLS: [&str; 3] = ["ctx_search", "ctx_tree", "ctx_glob"];
25
26struct McpEntitlements {
27 config: std::sync::Arc<lean_ctx::core::config::Config>,
28}
29
30struct PreparedMcpCall {
31 name: String,
32 args: Option<Map<String, Value>>,
33 minimal: bool,
34 config: std::sync::Arc<lean_ctx::core::config::Config>,
35 machine_readable: bool,
36 auto_context: Option<String>,
37 throttle_warning: Option<String>,
38 args_fp: String,
39 decision_context: Option<lean_ctx::core::execution_lifecycle::TaskContext>,
40 kernel_handoff: lean_ctx::core::context_kernel::bridge::runtime::KernelPlanningHandoff,
41 cache_key: Option<ResponseCacheKey>,
42}
43
44enum PreparedCallResult {
45 Terminal(CallToolResult, &'static str),
46 Cached(CallToolResult),
47 Ready(Box<PreparedMcpCall>),
48}
49
50impl PreparedCallResult {
51 fn into_replay_override(self) -> Option<CallToolResult> {
52 match self {
53 Self::Terminal(result, _) => Some(result),
54 Self::Cached(_) | Self::Ready(_) => None,
55 }
56 }
57}
58
59enum McpDispatched {
60 Terminal(CallToolResult, &'static str),
61 Raw {
62 call: Box<PreparedMcpCall>,
63 primitive: super::pipeline::McpPrimitive,
64 },
65}
66
67fn host_hook_refusal(mut result: CallToolResult) -> CallToolResult {
72 result.is_error = Some(true);
73 result
74}
75
76fn loop_throttle_refusal(
80 throttle: &lean_ctx::core::loop_detection::ThrottleResult,
81) -> Option<CallToolResult> {
82 (throttle.level == lean_ctx::core::loop_detection::ThrottleLevel::Blocked).then(|| {
83 CallToolResult::error(vec![ContentBlock::text(
84 throttle.message.clone().unwrap_or_default(),
85 )])
86 })
87}
88
89struct McpProcessedCall {
90 result: CallToolResult,
91 pipeline: Option<super::pipeline::McpProcessed>,
92 cache_key: Option<ResponseCacheKey>,
93 skip_reason: Option<&'static str>,
94}
95
96struct McpDriver<'a> {
97 server: &'a LeanCtxServer,
98 request: Option<CallToolRequestParams>,
99 entitlements: Option<McpEntitlements>,
100 prepared: Option<PreparedCallResult>,
101 checkpoint: Option<super::pipeline::McpCheckpointIntent>,
102 pending_cache_key: Option<ResponseCacheKey>,
103 native_receipt_capture: Option<Arc<crate::server::native_receipts::NativeReceiptCapture>>,
104 native_receipt_metadata: Option<Value>,
105 input_tokens: u64,
106}
107
108#[derive(Deserialize)]
109#[serde(rename_all = "camelCase")]
110struct CachedCallToolResult {
111 content: Vec<ContentBlock>,
112 #[serde(default)]
113 structured_content: Option<Value>,
114 #[serde(default)]
115 is_error: Option<bool>,
116 #[serde(default, rename = "_meta")]
117 meta: Option<Meta>,
118}
119
120pub(super) fn response_cache_key(
121 tool_name: &str,
122 arguments: Option<&Map<String, Value>>,
123 project_root: &str,
124) -> Option<ResponseCacheKey> {
125 if crate::server::native_receipts::requested(tool_name, arguments) {
126 return None;
128 }
129 if lean_ctx::core::workspace_authority::is_configured() {
132 return None;
133 }
134 if tool_name == "ctx_search"
137 && arguments.is_some_and(crate::tools::registered::ctx_search::uses_kernel_context)
138 {
139 return None;
140 }
141 let policy = lean_ctx::core::policy::runtime::active();
142 if policy
143 .as_ref()
144 .is_some_and(|policy| !policy.tool_allowed(tool_name))
145 {
146 return None;
147 }
148 if !CACHEABLE_TOOLS.contains(&tool_name) {
149 return None;
150 }
151 let revision = corpus_revision(arguments, project_root)?;
158 Some({
159 let mut input = Vec::with_capacity(project_root.len() + 1);
160 input.extend_from_slice(project_root.as_bytes());
161 input.push(0);
162 input.extend_from_slice(&revision.to_be_bytes());
163 input.push(0);
164 input.extend_from_slice(
165 &serde_json::to_vec(&arguments).expect("JSON arguments must serialize"),
166 );
167 if let Some(policy) = policy {
168 input.push(0);
169 input.extend_from_slice(
170 &serde_json::to_vec(&policy.resolved).expect("resolved policy is serializable"),
171 );
172 }
173 let digest = blake3::hash(&input);
174 let mut hash_bytes = [0; 8];
175 hash_bytes.copy_from_slice(&digest.as_bytes()[..8]);
176 let arguments_hash = u64::from_be_bytes(hash_bytes);
177 ResponseCacheKey::new(tool_name, arguments_hash, 0.0, 0)
178 })
179}
180
181fn corpus_revision(arguments: Option<&Map<String, Value>>, project_root: &str) -> Option<u64> {
184 let arg = arguments
185 .and_then(|args| args.get("path"))
186 .and_then(Value::as_str)
187 .filter(|path| !path.trim().is_empty());
188 let root = match arg {
189 Some(path) if std::path::Path::new(path).is_absolute() => std::path::PathBuf::from(path),
190 Some(path) if !project_root.is_empty() => std::path::Path::new(project_root).join(path),
191 None if !project_root.is_empty() => std::path::PathBuf::from(project_root),
192 Some(_) | None => return None,
193 };
194 if !root.exists() {
195 return Some(0);
197 }
198 lean_ctx::core::search_index::corpus_signature(&root.to_string_lossy(), true, false)
199}
200
201fn cache_allowed_for_current_egress(config: &lean_ctx::core::config::Config) -> bool {
202 !lean_ctx::core::context_admission::stored::source_authority_required()
207 && !lean_ctx::core::policy::runtime::is_active()
208 && !config.sensitivity_effective().enabled_effective()
209 && lean_ctx::core::redaction::redaction_enabled_for_active_role()
210}
211
212fn bound_cache_allowed(config: &lean_ctx::core::config::Config) -> bool {
221 lean_ctx::core::context_admission::stored::source_authority_required()
222 && !config.sensitivity_effective().enabled_effective()
223 && lean_ctx::core::redaction::redaction_enabled_for_active_role()
224}
225
226fn cache_lineage_id(key: &ResponseCacheKey) -> String {
228 format!("{}:{:016x}", key.model, key.hash)
229}
230
231pub(super) fn cached_call_result(
232 cache: &ResponseCache,
233 key: &ResponseCacheKey,
234) -> Option<CallToolResult> {
235 let response = cache.get(key);
236 lean_ctx::core::telemetry::global_metrics().record_cache(response.is_some());
237 response.and_then(|cached| {
238 serde_json::from_slice::<CachedCallToolResult>(&cached.body)
239 .ok()
240 .map(|cached| {
241 let mut result = CallToolResult::success(cached.content);
242 result.structured_content = cached.structured_content;
243 result.is_error = cached.is_error;
244 result.meta = cached.meta;
245 result
246 })
247 })
248}
249
250pub(super) fn cache_call_result(
251 cache: &ResponseCache,
252 key: ResponseCacheKey,
253 result: &CallToolResult,
254) {
255 if result.is_error == Some(true) {
256 return;
257 }
258 let Ok(body) = serde_json::to_vec(result) else {
259 return;
260 };
261 let tokens = lean_ctx::core::tokens::count_tokens(&String::from_utf8_lossy(&body))
262 .try_into()
263 .unwrap_or(u64::MAX);
264 cache.put(
265 key,
266 CachedResponse {
267 body,
268 status: 200,
269 tokens,
270 created_at: Instant::now(),
271 ttl: Duration::ZERO,
272 },
273 );
274}
275
276impl LeanCtxServer {
277 pub async fn call_tool_guarded(
281 &self,
282 request: CallToolRequestParams,
283 ) -> Result<CallToolResult, ErrorData> {
284 let authority = lean_ctx::core::workspace_authority::current();
287 lean_ctx::core::workspace_authority::scope(
288 authority,
289 self.session
290 .scope(Box::pin(self.call_tool_guarded_bound(request))),
291 )
292 .await
293 }
294
295 async fn call_tool_guarded_bound(
296 &self,
297 request: CallToolRequestParams,
298 ) -> Result<CallToolResult, ErrorData> {
299 let project = self
300 .session
301 .read()
302 .await
303 .project_root
304 .as_ref()
305 .map(std::path::PathBuf::from);
306 let tick = self
317 .background_tick
318 .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
319 + 1;
320 if tick == 1 || tick.is_multiple_of(10) {
321 let record_version = tick == 1 || tick.is_multiple_of(100);
324 std::thread::spawn(move || {
325 if record_version
326 && lean_ctx::core::telemetry_send::telemetry_send_eligible()
327 && let Err(error) =
328 lean_ctx::core::telemetry_aggregate::record_current_version()
329 {
330 tracing::debug!("telemetry version aggregate unavailable: {error}");
331 }
332 lean_ctx::core::telemetry_send::send_telemetry(
333 lean_ctx::core::telemetry_aggregate::SendTrigger::Periodic,
334 );
335 });
336 }
337 lean_ctx::core::policy::runtime::REQUEST_PROJECT
338 .scope(std::cell::RefCell::new(project), async {
339 self.resolve_roots_once().await;
342 self.session.pin();
343 let project = self.session.read().await.project_root.clone();
344 lean_ctx::core::policy::runtime::bind_request_project(project.as_deref());
345 let admissions =
349 lean_ctx::core::context_admission::capture::AdmissionCapture::for_call(
350 lean_ctx::core::context_admission::capture::CallSnapshot::take(),
351 );
352 let tool_name = request.name.to_string();
353 let result = lean_ctx::core::context_admission::capture::ADMISSIONS
354 .scope(
355 Some(admissions.clone()),
356 self.call_tool_guarded_scoped(request),
357 )
358 .await;
359 let result = result.map_err(|error| {
363 if lean_ctx::core::policy::runtime::is_active() {
364 ErrorData::internal_error(
365 "Tool request failed under the current policy",
366 None,
367 )
368 } else {
369 error
370 }
371 });
372 self.finish_gateway_receipt(admissions, result, &tool_name)
374 .await
375 })
376 .await
377 }
378
379 async fn call_tool_guarded_scoped(
380 &self,
381 request: CallToolRequestParams,
382 ) -> Result<CallToolResult, ErrorData> {
383 if let Some(line) = crate::server::dynamic_tools::withheld_hive_tool_refusal(
386 &request.name,
387 request.arguments.as_ref(),
388 ) {
389 return Ok(CallToolResult::error(vec![ContentBlock::text(line)]));
390 }
391 if crate::server::native_receipts::requested(&request.name, request.arguments.as_ref())
392 && let Some(authority) = &self.native_receipt_authority
393 {
394 authority
396 .validate_current()
397 .map_err(|code| ErrorData::internal_error(code, None))?;
398 }
399 let config = lean_ctx::core::config::Config::load_arc();
400 let tool_name = request.name.as_ref().to_string();
401 let semantic_call =
405 crate::tools::registered::ctx_call::resolve(&tool_name, request.arguments.as_ref())
406 .ok();
407 let query = semantic_call
408 .as_ref()
409 .and_then(|(_, args)| args.as_ref())
410 .and_then(|args| {
411 args.get("query")
412 .and_then(Value::as_str)
413 .or_else(|| args.get("task").and_then(Value::as_str))
414 })
415 .map(str::to_owned);
416 let input_tokens = request
417 .arguments
418 .as_ref()
419 .and_then(|arguments| serde_json::to_vec(arguments).ok())
420 .map_or(0, |bytes| {
421 u64::try_from(bytes.len() / 4).unwrap_or(u64::MAX)
422 });
423 let raw_idempotency_key = request.meta.as_ref().and_then(|meta| {
424 meta.0
425 .get("requestId")
426 .or_else(|| meta.0.get("idempotencyKey"))
427 .and_then(Value::as_str)
428 .map(str::to_owned)
429 });
430 let (session_id, project_root, stated_task) = {
431 let session = self.session.read().await;
432 let stated_task = session
433 .task
434 .as_ref()
435 .filter(|task| {
436 crate::tools::registered::ctx_read::task_intent_steers_read(
437 task.intent.as_deref(),
438 )
439 })
440 .map(|task| task.description.clone());
441 (
442 session.id.clone(),
443 session.project_root.clone(),
444 stated_task,
445 )
446 };
447 let query = query.or(stated_task);
448 let task_identity = self
449 .native_receipt_authority
450 .as_ref()
451 .map(|authority| authority.admit_task_identity(project_root.as_deref()))
452 .transpose()
453 .map_err(|code| ErrorData::internal_error(code, None))?
454 .flatten();
455 let agent_id = match self.agent_id.read().await.clone() {
456 Some(agent_id) => agent_id,
457 None => self
458 .presence_agent_id
459 .read()
460 .await
461 .clone()
462 .unwrap_or_else(|| "mcp-agent".to_owned()),
463 };
464 let payload_fingerprint = request.arguments.as_ref().map_or_else(String::new, |args| {
465 lean_ctx::core::loop_detection::LoopDetector::fingerprint(&Value::Object(args.clone()))
466 });
467 let idempotency_key = raw_idempotency_key.map(|key| {
468 format!("mcp:{session_id}:{agent_id}:{tool_name}:{payload_fingerprint}:{key}")
469 });
470 let client_name = self.client_name.read().await.clone();
471 let release_name = semantic_call
472 .as_ref()
473 .map_or(tool_name.as_str(), |(name, _)| name)
474 .to_owned();
475 let decision_loop_enabled = config.decision_loop.enabled;
476 let lifecycle = lean_ctx::core::execution_lifecycle::ExecutionLifecycle::global();
477 let replay_key = idempotency_key.clone();
480 let run = lifecycle.run_with_task_identity(
481 lean_ctx::core::execution_lifecycle::ToolRequest {
482 tool_name: semantic_call.map_or(tool_name, |(name, _)| name),
483 query,
484 session_id,
485 agent_id,
486 surface: lean_ctx::core::execution_lifecycle::ToolSurface::Mcp,
487 idempotency_key,
488 },
489 lean_ctx::core::execution_lifecycle::RuntimeContext {
490 client_name: Some(client_name),
491 project_root,
492 },
493 lean_ctx::core::execution_lifecycle::ProductEntitlements {
494 autopilot: decision_loop_enabled,
495 personalized_learning: false,
496 },
497 McpDriver {
498 server: self,
499 request: Some(request),
500 entitlements: Some(McpEntitlements { config }),
501 prepared: None,
502 checkpoint: None,
503 pending_cache_key: None,
504 native_receipt_capture: None,
505 native_receipt_metadata: None,
506 input_tokens,
507 },
508 task_identity,
509 );
510 let outcome = replay_lineage::scope(replay_key, run)
511 .await
512 .map_err(|error| match error {
513 lean_ctx::core::execution_lifecycle::LifecycleRunError::Dispatch(error) => error,
514 lean_ctx::core::execution_lifecycle::LifecycleRunError::ReplayTypeMismatch => {
515 ErrorData::invalid_request("idempotent execution contract mismatch", None)
516 }
517 lean_ctx::core::execution_lifecycle::LifecycleRunError::Aborted(outcome) => {
518 ErrorData::internal_error(
519 format!(
520 "idempotent execution already terminated: {}",
521 outcome.accepted_outcome.outcome_id.as_str()
522 ),
523 None,
524 )
525 }
526 });
527 crate::server::policy_guard::release_outcome(&release_name, outcome)
530 }
531
532 async fn prepare_tool_call(
533 &self,
534 request: CallToolRequestParams,
535 entitlements: McpEntitlements,
536 ) -> Result<PreparedCallResult, ErrorData> {
537 elicitation::increment_call();
538
539 let original_name = request.name.as_ref().to_string();
540 let meta_interaction_mode = request
542 .meta
543 .as_ref()
544 .and_then(|m| m.0.get("interactionMode"))
545 .and_then(|v| v.as_str())
546 .and_then(crate::tools::InteractionMode::from_meta_str);
547 let (resolved_name, resolved_args) = crate::tools::registered::ctx_call::normalize_outer(
548 &original_name,
549 request.arguments.as_ref(),
550 )?;
551 let name = resolved_name.as_str();
552 let args = resolved_args.as_ref();
553 let config = entitlements.config;
554 let audit_agent = self.audit_agent_id().await;
555 if let Some(denied) = Self::guard_role_and_policy(name, &audit_agent) {
556 return Ok(PreparedCallResult::Terminal(
557 denied,
558 "role or policy denied",
559 ));
560 }
561
562 let inner_call = if name == "ctx_call" {
568 match crate::tools::registered::ctx_call::resolve_inner(args) {
569 Ok(inner) => Some(inner),
570 Err(error) => {
573 return Ok(PreparedCallResult::Terminal(
574 CallToolResult::error(vec![ContentBlock::text(error.message.to_string())]),
575 "invalid params",
576 ));
577 }
578 }
579 } else {
580 None
581 };
582 let (guard_name, guard_args): (&str, Option<&serde_json::Map<_, _>>) = match &inner_call {
583 Some((n, a)) => (n.as_str(), a.as_ref()),
584 None => (name, args),
585 };
586 if inner_call.is_some() {
589 if let Some(denied) = Self::guard_role_and_policy(guard_name, &audit_agent) {
590 return Ok(PreparedCallResult::Terminal(
591 denied,
592 "inner role or policy denied",
593 ));
594 }
595 if let Some(denied) = self.guard_workflow(guard_name).await {
596 return Ok(PreparedCallResult::Terminal(
597 denied,
598 "inner workflow denied",
599 ));
600 }
601 }
602
603 if let Some(mode) = meta_interaction_mode {
605 let prev = self
606 .interaction_mode
607 .swap(mode as u8, std::sync::atomic::Ordering::Relaxed);
608 if prev != mode as u8 {
609 tracing::info!(
610 ?mode,
611 "interaction mode changed — sending tools/list_changed"
612 );
613 if let Some(peer) = self.peer.read().await.as_ref() {
614 crate::server::notifications::send_tools_list_changed(peer).await;
615 }
616 }
617 }
618
619 {
621 use crate::tools::InteractionMode;
622 let current = InteractionMode::from_u8(
623 self.interaction_mode
624 .load(std::sync::atomic::Ordering::Relaxed),
625 );
626 if current == InteractionMode::Plan {
627 let plan_tools = lean_ctx::core::editor_registry::plan_mode::plan_mode_tools();
628 if !plan_tools.contains(&guard_name) {
629 let result = CallToolResult::error(vec![ContentBlock::text(format!(
630 "[PLAN MODE] Tool '{guard_name}' is not available in plan/readonly mode. Switch to agent/edit mode to use this tool."
631 ))]);
632 return Ok(PreparedCallResult::Terminal(result, "plan mode denied"));
633 }
634 }
635 }
636
637 let agent = match self.agent_id.read().await.clone() {
641 Some(agent) => Some(agent),
642 None => self.presence_agent_id.read().await.clone(),
643 };
644 if let Some(message) =
645 lean_ctx_hive::bus::control::tool_refusal(agent.as_deref(), guard_name)
646 {
647 return Ok(PreparedCallResult::Terminal(
648 CallToolResult::error(vec![ContentBlock::text(message)]),
649 "hive control denied",
650 ));
651 }
652 if let Some(message) = lean_ctx_hive::bus::veto::take_refusal(agent.as_deref(), guard_name)
655 {
656 return Ok(PreparedCallResult::Terminal(
657 CallToolResult::error(vec![ContentBlock::text(message)]),
658 "hive veto",
659 ));
660 }
661
662 if let Some(blocked) = Self::guard_egress(guard_name, guard_args, &audit_agent) {
663 return Ok(PreparedCallResult::Terminal(blocked, "egress denied"));
664 }
665
666 if let Some(blocked) = self.guard_workflow(name).await {
667 return Ok(PreparedCallResult::Terminal(blocked, "workflow denied"));
668 }
669
670 let machine_readable = self
688 .registry
689 .as_ref()
690 .and_then(|registry| registry.get_arc(guard_name))
691 .is_some_and(|tool| tool.produces_machine_readable(guard_args));
692
693 let args_fp = args
698 .map(|a| {
699 lean_ctx::core::loop_detection::LoopDetector::fingerprint(
700 &serde_json::Value::Object(a.clone()),
701 )
702 })
703 .unwrap_or_default();
704 let throttle_result = {
705 let semantic_fp = guard_args
706 .map(|args| {
707 lean_ctx::core::loop_detection::LoopDetector::fingerprint(&Value::Object(
708 args.clone(),
709 ))
710 })
711 .unwrap_or_default();
712 let fp = &semantic_fp;
713 let detector_timeout = tokio::time::timeout(
714 std::time::Duration::from_secs(3),
715 self.loop_detector.write(),
716 )
717 .await;
718 if crate::server::dynamic_tools::INTERNAL_HOST_TOOLS.contains(&guard_name) {
719 lean_ctx::core::loop_detection::ThrottleResult::default()
722 } else if let Ok(mut detector) = detector_timeout {
723 let is_search =
724 lean_ctx::core::loop_detection::LoopDetector::is_search_tool(guard_name);
725 let is_search_shell = guard_name == "ctx_shell" && {
726 let cmd = guard_args
727 .and_then(|a| a.get("command"))
728 .and_then(|v| v.as_str())
729 .unwrap_or("");
730 lean_ctx::core::loop_detection::LoopDetector::is_search_shell_command(cmd)
731 };
732
733 if is_search || is_search_shell {
734 let search_pattern = guard_args.and_then(|a| {
735 a.get("pattern")
736 .or_else(|| a.get("query"))
737 .and_then(|v| v.as_str())
738 });
739 let shell_pattern = if is_search_shell {
740 guard_args
741 .and_then(|a| a.get("command"))
742 .and_then(|v| v.as_str())
743 .and_then(helpers::extract_search_pattern_from_command)
744 } else {
745 None
746 };
747 let pat = search_pattern.or(shell_pattern.as_deref());
748 detector.record_search(guard_name, fp, pat)
749 } else {
750 detector.record_call(guard_name, fp)
751 }
752 } else {
753 tracing::warn!("pre-dispatch: loop_detector write-lock timeout (3s), skipping");
754 lean_ctx::core::loop_detection::ThrottleResult::default()
755 }
756 };
757
758 if let Some(result) = loop_throttle_refusal(&throttle_result) {
759 return Ok(PreparedCallResult::Terminal(
760 result,
761 "loop throttle blocked",
762 ));
763 }
764
765 let throttle_warning =
766 if throttle_result.level == lean_ctx::core::loop_detection::ThrottleLevel::Reduced {
767 throttle_result.message.clone()
768 } else {
769 None
770 };
771
772 if config.permission_inheritance_effective()
779 == lean_ctx::core::config::PermissionInheritance::On
780 {
781 let client_name = self.client_name.read().await.clone();
782 let project_root = self.session.read().await.project_root.clone();
783 let perm = permission_inheritance::check(
784 &client_name,
785 guard_name,
786 guard_args,
787 project_root.as_deref(),
788 &config,
789 );
790 if let Some(blocked) = permission_inheritance::into_call_tool_result(&perm) {
791 tracing::warn!(tool = guard_name, "held back by IDE permission inheritance");
792 return Ok(PreparedCallResult::Terminal(
793 blocked,
794 "permission inheritance denied",
795 ));
796 }
797 }
798
799 let project_root = self
800 .session
801 .read()
802 .await
803 .project_root
804 .clone()
805 .unwrap_or_default();
806 let bound_cache = bound_cache_allowed(&config);
809 let cache_key = (bound_cache || cache_allowed_for_current_egress(&config))
810 .then(|| response_cache_key(name, args, &project_root))
811 .flatten();
812 let call_start = std::time::Instant::now();
813 if let Some(cached) = cache_key
814 .as_ref()
815 .filter(|key| {
817 !bound_cache || replay_lineage::cache_entry_current(&cache_lineage_id(key))
818 })
819 .and_then(|key| cached_call_result(global_response_cache(), key))
820 {
821 self.record_tool_usage(
822 name,
823 call_start,
824 super::pipeline::result_failure(&cached, ""),
825 );
826 return Ok(PreparedCallResult::Cached(cached));
827 }
828
829 Ok(PreparedCallResult::Ready(Box::new(PreparedMcpCall {
830 name: name.to_owned(),
831 args: args.cloned(),
832 minimal: false,
833 config,
834 machine_readable,
835 auto_context: None,
836 throttle_warning,
837 args_fp,
838 decision_context: None,
839 kernel_handoff:
840 lean_ctx::core::context_kernel::bridge::runtime::KernelPlanningHandoff::Suppressed,
841 cache_key,
842 })))
843 }
844
845 fn record_tool_usage(
849 &self,
850 name: &str,
851 started: std::time::Instant,
852 failure: Option<lean_ctx::core::telemetry_failure::Failure>,
853 ) {
854 let Some(tool) = self
855 .registry
856 .as_ref()
857 .and_then(|registry| registry.static_name(name))
858 else {
859 return;
860 };
861 let latency_us = u64::try_from(started.elapsed().as_micros()).unwrap_or(u64::MAX);
862 lean_ctx::core::telemetry::global_metrics()
863 .record_named_tool_failure(tool, latency_us, failure);
864 }
865}
866
867#[async_trait::async_trait]
868impl lean_ctx::core::execution_lifecycle::ExecutionDriver for McpDriver<'_> {
869 type Primitive = McpDispatched;
870 type Processed = McpProcessedCall;
871 type Output = CallToolResult;
872 type Error = ErrorData;
873
874 async fn identify_caller(
875 &mut self,
876 _context: &lean_ctx::core::execution_lifecycle::TaskContext,
877 ) -> Result<lean_ctx::core::execution_lifecycle::StageDisposition, Self::Error> {
878 self.server
879 .ensure_session_presence()
880 .await
881 .map_err(|error| {
882 ErrorData::internal_error(
883 format!("agent bus registration is required before tool execution: {error}"),
884 None,
885 )
886 })?;
887 self.server.check_idle_expiry().await;
888 Ok(lean_ctx::core::execution_lifecycle::StageDisposition::Applied)
889 }
890
891 async fn resolve_workspace(
892 &mut self,
893 _context: &lean_ctx::core::execution_lifecycle::TaskContext,
894 ) -> Result<lean_ctx::core::execution_lifecycle::StageDisposition, Self::Error> {
895 self.server.resolve_roots_once().await;
896 let project = self.server.session.read().await.project_root.clone();
897 lean_ctx::core::policy::runtime::bind_request_project(project.as_deref());
898 Ok(lean_ctx::core::execution_lifecycle::StageDisposition::Applied)
899 }
900
901 async fn resolve_entitlements(
902 &mut self,
903 _context: &lean_ctx::core::execution_lifecycle::TaskContext,
904 ) -> Result<lean_ctx::core::execution_lifecycle::StageDisposition, Self::Error> {
905 if self.entitlements.is_none() {
906 return Err(ErrorData::internal_error(
907 "MCP entitlements were not resolved",
908 None,
909 ));
910 }
911 Ok(lean_ctx::core::execution_lifecycle::StageDisposition::Applied)
912 }
913
914 async fn apply_security_boundaries(
915 &mut self,
916 _context: &lean_ctx::core::execution_lifecycle::TaskContext,
917 ) -> Result<lean_ctx::core::execution_lifecycle::StageDisposition, Self::Error> {
918 let request = self.request.take().expect("MCP request consumed once");
919 let entitlements = self
920 .entitlements
921 .take()
922 .expect("MCP entitlements consumed once");
923 let host_hook = crate::server::dynamic_tools::targets_internal_host_tool(
924 request.name.as_ref(),
925 request.arguments.as_ref(),
926 );
927 let prepared = self.server.prepare_tool_call(request, entitlements).await?;
928 self.prepared = Some(match prepared {
929 PreparedCallResult::Terminal(result, reason) if host_hook => {
930 PreparedCallResult::Terminal(host_hook_refusal(result), reason)
931 }
932 other => other,
933 });
934 Ok(lean_ctx::core::execution_lifecycle::StageDisposition::Applied)
935 }
936
937 async fn validate_cached_replay(
938 &mut self,
939 context: &lean_ctx::core::execution_lifecycle::TaskContext,
940 ) -> Result<Option<Self::Output>, Self::Error> {
941 self.identify_caller(context).await?;
942 self.resolve_workspace(context).await?;
943 self.resolve_entitlements(context).await?;
944 self.apply_security_boundaries(context).await?;
945 Ok(self
952 .prepared
953 .take()
954 .expect("MCP replay admission consumed once")
955 .into_replay_override()
956 .or_else(replay_lineage::denial))
957 }
958
959 async fn gather_context_strategy(
960 &mut self,
961 context: &lean_ctx::core::execution_lifecycle::TaskContext,
962 ) -> Result<lean_ctx::core::execution_lifecycle::StageDisposition, Self::Error> {
963 let Some(PreparedCallResult::Ready(call)) = self.prepared.as_mut() else {
964 return Ok(
965 lean_ctx::core::execution_lifecycle::StageDisposition::Skipped(
966 "admission returned a terminal result",
967 ),
968 );
969 };
970 *self.server.task_envelope.write().await = Some(context.envelope.clone());
971 call.minimal = call.config.minimal_overhead_effective();
972 if !call.machine_readable {
975 let (task, project_root) = {
976 let session = self.server.session.read().await;
977 (
978 session.task.as_ref().map(|task| task.description.clone()),
979 session.project_root.clone(),
980 )
981 };
982 if !crate::tools::autonomy::delivers_session_briefing(&call.name, call.args.as_ref()) {
983 crate::tools::autonomy::apply_auto_load_packages_once(
984 &self.server.autonomy,
985 project_root.as_deref(),
986 );
987 return Ok(lean_ctx::core::execution_lifecycle::StageDisposition::Applied);
988 }
989 call.auto_context = lean_ctx::core::session::caller::scope(
991 Some(self.server.session.instance()),
992 || {
993 crate::tools::autonomy::session_wakeup_briefing(
994 &self.server.autonomy,
995 &call.name,
996 task.as_deref(),
997 project_root.as_deref(),
998 )
999 },
1000 );
1001 }
1002 Ok(lean_ctx::core::execution_lifecycle::StageDisposition::Applied)
1003 }
1004
1005 async fn ask_autopilot(
1006 &mut self,
1007 context: &lean_ctx::core::execution_lifecycle::TaskContext,
1008 ) -> Result<lean_ctx::core::execution_lifecycle::StageDisposition, Self::Error> {
1009 if !context.entitlements.autopilot {
1010 if let Some(PreparedCallResult::Ready(call)) = self.prepared.as_mut() {
1011 call.kernel_handoff =
1012 lean_ctx::core::context_kernel::bridge::runtime::KernelPlanningHandoff::Suppressed;
1013 }
1014 return Ok(
1015 lean_ctx::core::execution_lifecycle::StageDisposition::Skipped("not entitled"),
1016 );
1017 }
1018 let Some(PreparedCallResult::Ready(call)) = self.prepared.as_mut() else {
1019 return Ok(
1020 lean_ctx::core::execution_lifecycle::StageDisposition::Skipped(
1021 "admission returned a terminal result",
1022 ),
1023 );
1024 };
1025 *self.server.task_envelope.write().await = Some(context.envelope.clone());
1026 call.decision_context = Some(context.clone());
1027 let (disposition, handoff) = kernel::plan(self.server, call, context).await;
1028 call.kernel_handoff = handoff;
1029 Ok(disposition)
1030 }
1031
1032 async fn dispatch_primitive(
1033 &mut self,
1034 context: &lean_ctx::core::execution_lifecycle::TaskContext,
1035 ) -> Result<
1036 (
1037 Self::Primitive,
1038 lean_ctx::core::execution_lifecycle::StageDisposition,
1039 ),
1040 Self::Error,
1041 > {
1042 match self.prepared.take().expect("MCP admission consumed once") {
1043 PreparedCallResult::Terminal(result, reason) => Ok((
1044 McpDispatched::Terminal(result, reason),
1045 lean_ctx::core::execution_lifecycle::StageDisposition::Applied,
1046 )),
1047 PreparedCallResult::Cached(result) => Ok((
1048 McpDispatched::Terminal(result, "response cache hit"),
1049 lean_ctx::core::execution_lifecycle::StageDisposition::Applied,
1050 )),
1051 PreparedCallResult::Ready(call) => {
1052 let (guard_name, guard_args) =
1056 crate::tools::registered::ctx_call::resolve(&call.name, call.args.as_ref())?;
1057 let audit_agent = self.server.audit_agent_id().await;
1058 if let Some(mut denied) =
1059 LeanCtxServer::guard_role_and_policy(&guard_name, &audit_agent).or_else(|| {
1060 LeanCtxServer::guard_egress(&guard_name, guard_args.as_ref(), &audit_agent)
1061 })
1062 {
1063 if crate::server::dynamic_tools::INTERNAL_HOST_TOOLS
1064 .contains(&guard_name.as_str())
1065 {
1066 denied = host_hook_refusal(denied);
1067 }
1068 return Ok((
1069 McpDispatched::Terminal(denied, "policy changed before dispatch"),
1070 lean_ctx::core::execution_lifecycle::StageDisposition::Applied,
1071 ));
1072 }
1073 let shell_command = (guard_name == "ctx_shell")
1076 .then(|| {
1077 guard_args
1078 .as_ref()
1079 .and_then(|args| args.get("command"))
1080 .and_then(Value::as_str)
1081 })
1082 .flatten();
1083 let execute_items = (guard_name == "ctx_execute")
1086 .then(|| {
1087 guard_args
1088 .as_ref()
1089 .map(crate::server::shell_approval::execute_items)
1090 })
1091 .flatten();
1092 let approval = match (shell_command, execute_items) {
1093 (Some(command), _) => Some(self.server.shell_approval(command).await),
1094 (None, Some(items)) => Some(self.server.execute_approval(&items).await),
1095 (None, None) => None,
1096 };
1097 let shell_grant: Arc<[String]> = match approval {
1098 Some(outcome) => match outcome {
1099 crate::server::shell_approval::Outcome::Granted(programs) => {
1100 Arc::from(programs)
1101 }
1102 crate::server::shell_approval::Outcome::Refused(message) => {
1103 return Ok((
1104 McpDispatched::Terminal(
1105 CallToolResult::error(vec![ContentBlock::text(message)]),
1106 "shell command not approved",
1107 ),
1108 lean_ctx::core::execution_lifecycle::StageDisposition::Applied,
1109 ));
1110 }
1111 crate::server::shell_approval::Outcome::NotNeeded => Arc::from(Vec::new()),
1112 },
1113 None => Arc::from(Vec::new()),
1114 };
1115 if crate::server::native_receipts::requested(&call.name, call.args.as_ref()) {
1116 self.native_receipt_capture = self
1117 .server
1118 .native_receipt_authority
1119 .as_ref()
1120 .map(|authority| {
1121 Arc::new(crate::server::native_receipts::NativeReceiptCapture::new(
1122 authority.clone(),
1123 context.envelope.clone(),
1124 ))
1125 });
1126 }
1127 let dispatch_start = std::time::Instant::now();
1128 let dispatch = super::pipeline::dispatch_primitive(
1129 self.server,
1130 &call.name,
1131 call.args.as_ref(),
1132 call.minimal,
1133 &call.args_fp,
1134 );
1135 let primitive = crate::server::shell_approval::SHELL_GRANT
1136 .scope(
1137 shell_grant,
1138 crate::server::native_receipts::NATIVE_RECEIPT_CAPTURE.scope(
1139 self.native_receipt_capture.clone(),
1140 lean_ctx::core::context_kernel::bridge::runtime::KERNEL_PLANNING_HANDOFF
1141 .scope(call.kernel_handoff.clone(), dispatch),
1142 ),
1143 )
1144 .await;
1145 self.server.record_tool_usage(
1146 &call.name,
1147 dispatch_start,
1148 match &primitive {
1149 Ok(primitive) => primitive.failure(),
1150 Err(error) => Some(super::pipeline::error_failure(error)),
1151 },
1152 );
1153 let primitive = primitive?;
1154 Ok((
1155 McpDispatched::Raw { call, primitive },
1156 lean_ctx::core::execution_lifecycle::StageDisposition::Applied,
1157 ))
1158 }
1159 }
1160 }
1161
1162 async fn reversible_post_process(
1163 &mut self,
1164 _context: &lean_ctx::core::execution_lifecycle::TaskContext,
1165 primitive: Self::Primitive,
1166 ) -> Result<
1167 (
1168 Self::Processed,
1169 lean_ctx::core::execution_lifecycle::StageDisposition,
1170 ),
1171 Self::Error,
1172 > {
1173 match primitive {
1174 McpDispatched::Terminal(result, reason) => Ok((
1175 McpProcessedCall {
1176 result,
1177 pipeline: None,
1178 cache_key: None,
1179 skip_reason: Some(reason),
1180 },
1181 lean_ctx::core::execution_lifecycle::StageDisposition::Skipped(reason),
1182 )),
1183 McpDispatched::Raw { call, primitive } => {
1184 let disposition = match &primitive {
1185 super::pipeline::McpPrimitive::Raw(_) => {
1186 lean_ctx::core::execution_lifecycle::StageDisposition::Applied
1187 }
1188 super::pipeline::McpPrimitive::Terminal(_, reason) => {
1189 lean_ctx::core::execution_lifecycle::StageDisposition::Skipped(reason)
1190 }
1191 };
1192 let mut processed = super::pipeline::reversible_post_process(
1193 self.server,
1194 &call.name,
1195 call.args.as_ref(),
1196 call.minimal,
1197 call.config,
1198 call.machine_readable,
1199 call.auto_context,
1200 call.throttle_warning,
1201 call.decision_context,
1202 primitive,
1203 )
1204 .await?;
1205 if self.native_receipt_capture.is_some() {
1206 processed.freeze_receipt_delivery();
1207 }
1208 Ok((
1209 McpProcessedCall {
1210 result: processed.result.clone(),
1211 pipeline: Some(processed),
1212 cache_key: call.cache_key,
1213 skip_reason: None,
1214 },
1215 disposition,
1216 ))
1217 }
1218 }
1219 }
1220
1221 async fn record_context_ir(
1222 &mut self,
1223 _context: &lean_ctx::core::execution_lifecycle::TaskContext,
1224 processed: &Self::Processed,
1225 ) -> Result<lean_ctx::core::execution_lifecycle::StageDisposition, Self::Error> {
1226 Ok(match processed.pipeline.as_ref() {
1227 Some(pipeline) => super::pipeline::record_context_ir(self.server, pipeline).await,
1228 None => lean_ctx::core::execution_lifecycle::StageDisposition::Skipped(
1229 processed.skip_reason.unwrap_or("terminal result"),
1230 ),
1231 })
1232 }
1233
1234 async fn record_ledger(
1235 &mut self,
1236 _context: &lean_ctx::core::execution_lifecycle::TaskContext,
1237 processed: &Self::Processed,
1238 ) -> Result<lean_ctx::core::execution_lifecycle::StageDisposition, Self::Error> {
1239 if let Some(capture) = &self.native_receipt_capture {
1240 self.native_receipt_metadata = Some(
1241 capture
1242 .publish(&processed.result)
1243 .map_err(|code| ErrorData::internal_error(code, None))?,
1244 );
1245 }
1246 Ok(match processed.pipeline.as_ref() {
1247 Some(pipeline) => super::pipeline::record_ledger(self.server, pipeline).await?,
1248 None => lean_ctx::core::execution_lifecycle::StageDisposition::Skipped(
1249 processed.skip_reason.unwrap_or("terminal result"),
1250 ),
1251 })
1252 }
1253
1254 async fn checkpoint(
1255 &mut self,
1256 _context: &lean_ctx::core::execution_lifecycle::TaskContext,
1257 output: &mut Self::Output,
1258 ) -> Result<lean_ctx::core::execution_lifecycle::StageDisposition, Self::Error> {
1259 let disposition =
1260 super::pipeline::record_checkpoint(self.server, self.checkpoint.take(), output).await;
1261 if let Some(key) = self.pending_cache_key.take() {
1262 let bindable = !lean_ctx::core::context_admission::stored::source_authority_required()
1266 || replay_lineage::bind_cache_entry(&cache_lineage_id(&key));
1267 if bindable {
1268 cache_call_result(global_response_cache(), key, output);
1269 }
1270 }
1271 Ok(disposition)
1272 }
1273
1274 fn output_from_processed(&mut self, mut processed: Self::Processed) -> Self::Output {
1275 if let Some(pipeline) = processed.pipeline.as_mut() {
1276 self.checkpoint = pipeline.checkpoint.take();
1277 }
1278 self.pending_cache_key = processed.cache_key;
1279 if let Some(receipt) = self.native_receipt_metadata.take() {
1280 processed
1281 .result
1282 .meta
1283 .get_or_insert_with(Meta::new)
1284 .0
1285 .insert("canonical_receipt".into(), receipt);
1286 }
1287 processed.result
1288 }
1289
1290 fn observe(
1291 &self,
1292 result: &Result<
1293 Self::Output,
1294 lean_ctx::core::execution_lifecycle::LifecycleRunError<Self::Error>,
1295 >,
1296 ) -> lean_ctx::core::execution_lifecycle::CompletionObservation {
1297 let (output_tokens, success) = result.as_ref().map_or((0, false), |response| {
1298 (
1299 u64::try_from(format!("{response:?}").len() / 4).unwrap_or(u64::MAX),
1300 response.is_error != Some(true),
1301 )
1302 });
1303 lean_ctx::core::execution_lifecycle::CompletionObservation::tool_result(
1304 self.input_tokens,
1305 output_tokens,
1306 "mcp-tool",
1307 success,
1308 )
1309 }
1310}
1311
1312#[cfg(test)]
1313#[path = "guarded_tests.rs"]
1314mod tests;