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, MetaObject};
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<MetaObject>,
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 project_only_output() -> bool {
229 lean_ctx::core::context_admission::capture::current()
230 .is_some_and(|capture| capture.output_origins().is_empty())
231}
232
233fn cache_lineage_id(key: &ResponseCacheKey) -> String {
235 format!("{}:{:016x}", key.model, key.hash)
236}
237
238pub(super) fn cached_call_result(
239 cache: &ResponseCache,
240 key: &ResponseCacheKey,
241) -> Option<CallToolResult> {
242 let response = cache.get(key);
243 lean_ctx::core::telemetry::global_metrics().record_cache(response.is_some());
244 response.and_then(|cached| {
245 serde_json::from_slice::<CachedCallToolResult>(&cached.body)
246 .ok()
247 .map(|cached| {
248 let mut result = CallToolResult::success(cached.content);
249 result.structured_content = cached.structured_content;
250 result.is_error = cached.is_error;
251 result.meta = cached.meta;
252 result
253 })
254 })
255}
256
257pub(super) fn cache_call_result(
258 cache: &ResponseCache,
259 key: ResponseCacheKey,
260 result: &CallToolResult,
261) {
262 if result.is_error == Some(true) {
263 return;
264 }
265 let Ok(body) = serde_json::to_vec(result) else {
266 return;
267 };
268 let tokens = lean_ctx::core::tokens::count_tokens(&String::from_utf8_lossy(&body))
269 .try_into()
270 .unwrap_or(u64::MAX);
271 cache.put(
272 key,
273 CachedResponse {
274 body,
275 status: 200,
276 tokens,
277 created_at: Instant::now(),
278 ttl: Duration::ZERO,
279 },
280 );
281}
282
283impl LeanCtxServer {
284 pub async fn call_tool_guarded(
288 &self,
289 request: CallToolRequestParams,
290 ) -> Result<CallToolResult, ErrorData> {
291 let authority = lean_ctx::core::workspace_authority::current();
294 let rights = lean_ctx::core::session_rights::current();
297 lean_ctx::core::session_rights::scope(
298 rights,
299 lean_ctx::core::workspace_authority::scope(
300 authority,
301 self.session
302 .scope(Box::pin(self.call_tool_guarded_bound(request))),
303 ),
304 )
305 .await
306 }
307
308 async fn call_tool_guarded_bound(
309 &self,
310 request: CallToolRequestParams,
311 ) -> Result<CallToolResult, ErrorData> {
312 let project = self
313 .session
314 .read()
315 .await
316 .project_root
317 .as_ref()
318 .map(std::path::PathBuf::from);
319 let tick = self
330 .background_tick
331 .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
332 + 1;
333 if tick == 1 || tick.is_multiple_of(10) {
334 let record_version = tick == 1 || tick.is_multiple_of(100);
337 std::thread::spawn(move || {
338 if record_version
339 && lean_ctx::core::telemetry_send::telemetry_send_eligible()
340 && let Err(error) =
341 lean_ctx::core::telemetry_aggregate::record_current_version()
342 {
343 tracing::debug!("telemetry version aggregate unavailable: {error}");
344 }
345 lean_ctx::core::telemetry_send::send_telemetry(
346 lean_ctx::core::telemetry_aggregate::SendTrigger::Periodic,
347 );
348 });
349 }
350 lean_ctx::core::policy::runtime::REQUEST_PROJECT
351 .scope(std::cell::RefCell::new(project), async {
352 self.resolve_roots_once().await;
355 self.session.pin();
356 let (project, session_id) = {
357 let session = self.session.read().await;
358 lean_ctx::core::session::taint::bind(&session.id, &session.taint);
362 (session.project_root.clone(), session.id.clone())
363 };
364 lean_ctx::core::policy::runtime::bind_request_project(project.as_deref());
365 let admissions =
369 lean_ctx::core::context_admission::capture::AdmissionCapture::for_call(
370 lean_ctx::core::context_admission::capture::CallSnapshot::take(),
371 );
372 let tool_name = request.name.to_string();
373 let activity = crate::server::activity::PendingCall::new(
374 &tool_name,
375 request.arguments.as_ref(),
376 );
377 let result = lean_ctx::core::context_admission::capture::ADMISSIONS
378 .scope(
379 Some(admissions.clone()),
380 self.call_tool_guarded_scoped(request),
381 )
382 .await;
383 let result = result.map_err(|error| {
387 if lean_ctx::core::policy::runtime::is_active() {
388 ErrorData::internal_error(
389 "Tool request failed under the current policy",
390 None,
391 )
392 } else {
393 error
394 }
395 });
396 if let Some(taint) = lean_ctx::core::session::taint::snapshot_for(&session_id)
399 .filter(lean_ctx::core::context_admission::origin::InputTaint::is_tainted)
400 {
401 let mut session = self.session.write().await;
402 if session.id == session_id {
403 session.taint.merge(&taint);
404 }
405 }
406 {
407 let failed = result
408 .as_ref()
409 .map_or(true, |r| r.is_error.unwrap_or(false));
410 let (session_id, project_root) = {
411 let session = self.session.read().await;
412 (session.id.clone(), session.project_root.clone())
413 };
414 let client_name = self.client_name.read().await.clone();
415 activity.finish(&session_id, project_root.as_deref(), &client_name, failed);
416 }
417 self.finish_gateway_receipt(admissions, result, &tool_name)
419 .await
420 })
421 .await
422 }
423
424 async fn call_tool_guarded_scoped(
425 &self,
426 request: CallToolRequestParams,
427 ) -> Result<CallToolResult, ErrorData> {
428 if let Some(line) = crate::server::dynamic_tools::withheld_hive_tool_refusal(
431 &request.name,
432 request.arguments.as_ref(),
433 ) {
434 return Ok(CallToolResult::error(vec![ContentBlock::text(line)]));
435 }
436 if crate::server::native_receipts::requested(&request.name, request.arguments.as_ref())
437 && let Some(authority) = &self.native_receipt_authority
438 {
439 authority
441 .validate_current()
442 .map_err(|code| ErrorData::internal_error(code, None))?;
443 }
444 let config = lean_ctx::core::config::Config::load_arc();
445 let tool_name = request.name.as_ref().to_string();
446 let semantic_call =
450 crate::tools::registered::ctx_call::resolve(&tool_name, request.arguments.as_ref())
451 .ok();
452 let query = semantic_call
453 .as_ref()
454 .and_then(|(_, args)| args.as_ref())
455 .and_then(|args| {
456 args.get("query")
457 .and_then(Value::as_str)
458 .or_else(|| args.get("task").and_then(Value::as_str))
459 })
460 .map(str::to_owned);
461 let input_tokens = request
462 .arguments
463 .as_ref()
464 .and_then(|arguments| serde_json::to_vec(arguments).ok())
465 .map_or(0, |bytes| {
466 u64::try_from(bytes.len() / 4).unwrap_or(u64::MAX)
467 });
468 let raw_idempotency_key = request.meta.as_ref().and_then(|meta| {
469 meta.0
470 .get("requestId")
471 .or_else(|| meta.0.get("idempotencyKey"))
472 .and_then(Value::as_str)
473 .map(str::to_owned)
474 });
475 let (session_id, project_root, stated_task) = {
476 let session = self.session.read().await;
477 let stated_task = session
478 .task
479 .as_ref()
480 .filter(|task| {
481 crate::tools::registered::ctx_read::task_intent_steers_read(
482 task.intent.as_deref(),
483 )
484 })
485 .map(|task| task.description.clone());
486 (
487 session.id.clone(),
488 session.project_root.clone(),
489 stated_task,
490 )
491 };
492 let query = query.or(stated_task);
493 let task_identity = self
494 .native_receipt_authority
495 .as_ref()
496 .map(|authority| authority.admit_task_identity(project_root.as_deref()))
497 .transpose()
498 .map_err(|code| ErrorData::internal_error(code, None))?
499 .flatten();
500 let agent_id = match self.agent_id.read().await.clone() {
501 Some(agent_id) => agent_id,
502 None => self
503 .presence_agent_id
504 .read()
505 .await
506 .clone()
507 .unwrap_or_else(|| "mcp-agent".to_owned()),
508 };
509 let payload_fingerprint = request.arguments.as_ref().map_or_else(String::new, |args| {
510 lean_ctx::core::loop_detection::LoopDetector::fingerprint(&Value::Object(args.clone()))
511 });
512 let idempotency_key = raw_idempotency_key.map(|key| {
513 format!("mcp:{session_id}:{agent_id}:{tool_name}:{payload_fingerprint}:{key}")
514 });
515 let client_name = self.client_name.read().await.clone();
516 let release_name = semantic_call
517 .as_ref()
518 .map_or(tool_name.as_str(), |(name, _)| name)
519 .to_owned();
520 let decision_loop_enabled = config.decision_loop.enabled;
521 let lifecycle = lean_ctx::core::execution_lifecycle::ExecutionLifecycle::global();
522 let replay_key = idempotency_key.clone();
525 let run = lifecycle.run_with_task_identity(
526 lean_ctx::core::execution_lifecycle::ToolRequest {
527 tool_name: semantic_call.map_or(tool_name, |(name, _)| name),
528 query,
529 session_id,
530 agent_id,
531 surface: lean_ctx::core::execution_lifecycle::ToolSurface::Mcp,
532 idempotency_key,
533 },
534 lean_ctx::core::execution_lifecycle::RuntimeContext {
535 client_name: Some(client_name),
536 project_root,
537 },
538 lean_ctx::core::execution_lifecycle::ProductEntitlements {
539 autopilot: decision_loop_enabled,
540 personalized_learning: false,
541 },
542 McpDriver {
543 server: self,
544 request: Some(request),
545 entitlements: Some(McpEntitlements { config }),
546 prepared: None,
547 checkpoint: None,
548 pending_cache_key: None,
549 native_receipt_capture: None,
550 native_receipt_metadata: None,
551 input_tokens,
552 },
553 task_identity,
554 );
555 let outcome = replay_lineage::scope(replay_key, run)
556 .await
557 .map_err(|error| match error {
558 lean_ctx::core::execution_lifecycle::LifecycleRunError::Dispatch(error) => error,
559 lean_ctx::core::execution_lifecycle::LifecycleRunError::ReplayTypeMismatch => {
560 ErrorData::invalid_request("idempotent execution contract mismatch", None)
561 }
562 lean_ctx::core::execution_lifecycle::LifecycleRunError::Aborted(outcome) => {
563 ErrorData::internal_error(
564 format!(
565 "idempotent execution already terminated: {}",
566 outcome.accepted_outcome.outcome_id.as_str()
567 ),
568 None,
569 )
570 }
571 });
572 crate::server::policy_guard::release_outcome(&release_name, outcome)
575 }
576
577 async fn prepare_tool_call(
578 &self,
579 request: CallToolRequestParams,
580 entitlements: McpEntitlements,
581 ) -> Result<PreparedCallResult, ErrorData> {
582 elicitation::increment_call();
583
584 let original_name = request.name.as_ref().to_string();
585 let meta_interaction_mode = request
587 .meta
588 .as_ref()
589 .and_then(|m| m.0.get("interactionMode"))
590 .and_then(|v| v.as_str())
591 .and_then(crate::tools::InteractionMode::from_meta_str);
592 let (resolved_name, resolved_args) = crate::tools::registered::ctx_call::normalize_outer(
593 &original_name,
594 request.arguments.as_ref(),
595 )?;
596 let name = resolved_name.as_str();
597 let args = resolved_args.as_ref();
598 let config = entitlements.config;
599 let audit_agent = self.audit_agent_id().await;
600 if let Some(denied) = Self::guard_role_and_policy(name, &audit_agent) {
601 return Ok(PreparedCallResult::Terminal(
602 denied,
603 "role or policy denied",
604 ));
605 }
606
607 let inner_call = if name == "ctx_call" {
613 match crate::tools::registered::ctx_call::resolve_inner(args) {
614 Ok(inner) => Some(inner),
615 Err(error) => {
618 return Ok(PreparedCallResult::Terminal(
619 CallToolResult::error(vec![ContentBlock::text(error.message.to_string())]),
620 "invalid params",
621 ));
622 }
623 }
624 } else {
625 None
626 };
627 let (guard_name, guard_args): (&str, Option<&serde_json::Map<_, _>>) = match &inner_call {
628 Some((n, a)) => (n.as_str(), a.as_ref()),
629 None => (name, args),
630 };
631 if inner_call.is_some() {
634 if let Some(denied) = Self::guard_role_and_policy(guard_name, &audit_agent) {
635 return Ok(PreparedCallResult::Terminal(
636 denied,
637 "inner role or policy denied",
638 ));
639 }
640 if let Some(denied) = self.guard_workflow(guard_name).await {
641 return Ok(PreparedCallResult::Terminal(
642 denied,
643 "inner workflow denied",
644 ));
645 }
646 if let Some(denied) = self.guard_session_rights(guard_name) {
647 return Ok(PreparedCallResult::Terminal(
648 denied,
649 "inner session rights denied",
650 ));
651 }
652 }
653
654 if let Some(mode) = meta_interaction_mode {
656 let prev = self
657 .interaction_mode
658 .swap(mode as u8, std::sync::atomic::Ordering::Relaxed);
659 if prev != mode as u8 {
660 tracing::info!(
661 ?mode,
662 "interaction mode changed — sending tools/list_changed"
663 );
664 if let Some(peer) = self.peer.read().await.as_ref() {
665 crate::server::notifications::send_tools_list_changed(peer).await;
666 }
667 }
668 }
669
670 {
672 use crate::tools::InteractionMode;
673 let current = InteractionMode::from_u8(
674 self.interaction_mode
675 .load(std::sync::atomic::Ordering::Relaxed),
676 );
677 if current == InteractionMode::Plan {
678 let plan_tools = lean_ctx::core::editor_registry::plan_mode::plan_mode_tools();
679 if !plan_tools.contains(&guard_name) {
680 let result = CallToolResult::error(vec![ContentBlock::text(format!(
681 "[PLAN MODE] Tool '{guard_name}' is not available in plan/readonly mode. Switch to agent/edit mode to use this tool."
682 ))]);
683 return Ok(PreparedCallResult::Terminal(result, "plan mode denied"));
684 }
685 }
686 }
687
688 let agent = match self.agent_id.read().await.clone() {
692 Some(agent) => Some(agent),
693 None => self.presence_agent_id.read().await.clone(),
694 };
695 if let Some(message) =
696 lean_ctx_hive::bus::control::tool_refusal(agent.as_deref(), guard_name)
697 {
698 return Ok(PreparedCallResult::Terminal(
699 CallToolResult::error(vec![ContentBlock::text(message)]),
700 "hive control denied",
701 ));
702 }
703 if let Some(message) = lean_ctx_hive::bus::veto::take_refusal(agent.as_deref(), guard_name)
706 {
707 return Ok(PreparedCallResult::Terminal(
708 CallToolResult::error(vec![ContentBlock::text(message)]),
709 "hive veto",
710 ));
711 }
712
713 if let Some(blocked) = Self::guard_egress(guard_name, guard_args, &audit_agent) {
714 return Ok(PreparedCallResult::Terminal(blocked, "egress denied"));
715 }
716
717 if let Some(blocked) = self.guard_workflow(name).await {
718 return Ok(PreparedCallResult::Terminal(blocked, "workflow denied"));
719 }
720
721 if let Some(blocked) = self.guard_session_rights(name) {
722 return Ok(PreparedCallResult::Terminal(
723 blocked,
724 "session rights denied",
725 ));
726 }
727
728 let machine_readable = self
746 .registry
747 .as_ref()
748 .and_then(|registry| registry.get_arc(guard_name))
749 .is_some_and(|tool| tool.produces_machine_readable(guard_args));
750
751 let args_fp = args
756 .map(|a| {
757 lean_ctx::core::loop_detection::LoopDetector::fingerprint(
758 &serde_json::Value::Object(a.clone()),
759 )
760 })
761 .unwrap_or_default();
762 let throttle_result = {
763 let semantic_fp = guard_args
764 .map(|args| {
765 lean_ctx::core::loop_detection::LoopDetector::fingerprint(&Value::Object(
766 args.clone(),
767 ))
768 })
769 .unwrap_or_default();
770 let fp = &semantic_fp;
771 let detector_timeout = tokio::time::timeout(
772 std::time::Duration::from_secs(3),
773 self.loop_detector.write(),
774 )
775 .await;
776 if crate::server::dynamic_tools::INTERNAL_HOST_TOOLS.contains(&guard_name) {
777 lean_ctx::core::loop_detection::ThrottleResult::default()
780 } else if let Ok(mut detector) = detector_timeout {
781 let is_search =
782 lean_ctx::core::loop_detection::LoopDetector::is_search_tool(guard_name);
783 let is_search_shell = guard_name == "ctx_shell" && {
784 let cmd = guard_args
785 .and_then(|a| a.get("command"))
786 .and_then(|v| v.as_str())
787 .unwrap_or("");
788 lean_ctx::core::loop_detection::LoopDetector::is_search_shell_command(cmd)
789 };
790
791 if is_search || is_search_shell {
792 let search_pattern = guard_args.and_then(|a| {
793 a.get("pattern")
794 .or_else(|| a.get("query"))
795 .and_then(|v| v.as_str())
796 });
797 let shell_pattern = if is_search_shell {
798 guard_args
799 .and_then(|a| a.get("command"))
800 .and_then(|v| v.as_str())
801 .and_then(helpers::extract_search_pattern_from_command)
802 } else {
803 None
804 };
805 let pat = search_pattern.or(shell_pattern.as_deref());
806 detector.record_search(guard_name, fp, pat)
807 } else {
808 detector.record_call(guard_name, fp)
809 }
810 } else {
811 tracing::warn!("pre-dispatch: loop_detector write-lock timeout (3s), skipping");
812 lean_ctx::core::loop_detection::ThrottleResult::default()
813 }
814 };
815
816 if let Some(result) = loop_throttle_refusal(&throttle_result) {
817 return Ok(PreparedCallResult::Terminal(
818 result,
819 "loop throttle blocked",
820 ));
821 }
822
823 let throttle_warning =
824 if throttle_result.level == lean_ctx::core::loop_detection::ThrottleLevel::Reduced {
825 throttle_result.message.clone()
826 } else {
827 None
828 };
829
830 if config.permission_inheritance_effective()
837 == lean_ctx::core::config::PermissionInheritance::On
838 {
839 let client_name = self.client_name.read().await.clone();
840 let project_root = self.session.read().await.project_root.clone();
841 let perm = permission_inheritance::check(
842 &client_name,
843 guard_name,
844 guard_args,
845 project_root.as_deref(),
846 &config,
847 );
848 if let Some(blocked) = permission_inheritance::into_call_tool_result(&perm) {
849 tracing::warn!(tool = guard_name, "held back by IDE permission inheritance");
850 return Ok(PreparedCallResult::Terminal(
851 blocked,
852 "permission inheritance denied",
853 ));
854 }
855 }
856
857 let project_root = self
858 .session
859 .read()
860 .await
861 .project_root
862 .clone()
863 .unwrap_or_default();
864 let bound_cache = bound_cache_allowed(&config);
867 let cache_key = (bound_cache || cache_allowed_for_current_egress(&config))
868 .then(|| response_cache_key(name, args, &project_root))
869 .flatten();
870 let call_start = std::time::Instant::now();
871 if let Some(cached) = cache_key
872 .as_ref()
873 .filter(|key| {
875 !bound_cache || replay_lineage::cache_entry_current(&cache_lineage_id(key))
876 })
877 .and_then(|key| cached_call_result(global_response_cache(), key))
878 {
879 self.record_tool_usage(
880 name,
881 call_start,
882 super::pipeline::result_failure(&cached, ""),
883 );
884 return Ok(PreparedCallResult::Cached(cached));
885 }
886
887 Ok(PreparedCallResult::Ready(Box::new(PreparedMcpCall {
888 name: name.to_owned(),
889 args: args.cloned(),
890 minimal: false,
891 config,
892 machine_readable,
893 auto_context: None,
894 throttle_warning,
895 args_fp,
896 decision_context: None,
897 kernel_handoff:
898 lean_ctx::core::context_kernel::bridge::runtime::KernelPlanningHandoff::Suppressed,
899 cache_key,
900 })))
901 }
902
903 fn record_tool_usage(
907 &self,
908 name: &str,
909 started: std::time::Instant,
910 failure: Option<lean_ctx::core::telemetry_failure::Failure>,
911 ) {
912 let Some(tool) = self
913 .registry
914 .as_ref()
915 .and_then(|registry| registry.static_name(name))
916 else {
917 return;
918 };
919 let latency_us = u64::try_from(started.elapsed().as_micros()).unwrap_or(u64::MAX);
920 lean_ctx::core::telemetry::global_metrics()
921 .record_named_tool_failure(tool, latency_us, failure);
922 }
923}
924
925#[async_trait::async_trait]
926impl lean_ctx::core::execution_lifecycle::ExecutionDriver for McpDriver<'_> {
927 type Primitive = McpDispatched;
928 type Processed = McpProcessedCall;
929 type Output = CallToolResult;
930 type Error = ErrorData;
931
932 async fn identify_caller(
933 &mut self,
934 _context: &lean_ctx::core::execution_lifecycle::TaskContext,
935 ) -> Result<lean_ctx::core::execution_lifecycle::StageDisposition, Self::Error> {
936 self.server
937 .ensure_session_presence()
938 .await
939 .map_err(|error| {
940 ErrorData::internal_error(
941 format!("agent bus registration is required before tool execution: {error}"),
942 None,
943 )
944 })?;
945 self.server.check_idle_expiry().await;
946 Ok(lean_ctx::core::execution_lifecycle::StageDisposition::Applied)
947 }
948
949 async fn resolve_workspace(
950 &mut self,
951 _context: &lean_ctx::core::execution_lifecycle::TaskContext,
952 ) -> Result<lean_ctx::core::execution_lifecycle::StageDisposition, Self::Error> {
953 self.server.resolve_roots_once().await;
954 let project = self.server.session.read().await.project_root.clone();
955 lean_ctx::core::policy::runtime::bind_request_project(project.as_deref());
956 Ok(lean_ctx::core::execution_lifecycle::StageDisposition::Applied)
957 }
958
959 async fn resolve_entitlements(
960 &mut self,
961 _context: &lean_ctx::core::execution_lifecycle::TaskContext,
962 ) -> Result<lean_ctx::core::execution_lifecycle::StageDisposition, Self::Error> {
963 if self.entitlements.is_none() {
964 return Err(ErrorData::internal_error(
965 "MCP entitlements were not resolved",
966 None,
967 ));
968 }
969 Ok(lean_ctx::core::execution_lifecycle::StageDisposition::Applied)
970 }
971
972 async fn apply_security_boundaries(
973 &mut self,
974 _context: &lean_ctx::core::execution_lifecycle::TaskContext,
975 ) -> Result<lean_ctx::core::execution_lifecycle::StageDisposition, Self::Error> {
976 let request = self.request.take().expect("MCP request consumed once");
977 let entitlements = self
978 .entitlements
979 .take()
980 .expect("MCP entitlements consumed once");
981 let host_hook = crate::server::dynamic_tools::targets_internal_host_tool(
982 request.name.as_ref(),
983 request.arguments.as_ref(),
984 );
985 let prepared = self.server.prepare_tool_call(request, entitlements).await?;
986 self.prepared = Some(match prepared {
987 PreparedCallResult::Terminal(result, reason) if host_hook => {
988 PreparedCallResult::Terminal(host_hook_refusal(result), reason)
989 }
990 other => other,
991 });
992 Ok(lean_ctx::core::execution_lifecycle::StageDisposition::Applied)
993 }
994
995 async fn validate_cached_replay(
996 &mut self,
997 context: &lean_ctx::core::execution_lifecycle::TaskContext,
998 ) -> Result<Option<Self::Output>, Self::Error> {
999 self.identify_caller(context).await?;
1000 self.resolve_workspace(context).await?;
1001 self.resolve_entitlements(context).await?;
1002 self.apply_security_boundaries(context).await?;
1003 Ok(self
1010 .prepared
1011 .take()
1012 .expect("MCP replay admission consumed once")
1013 .into_replay_override()
1014 .or_else(replay_lineage::denial))
1015 }
1016
1017 async fn gather_context_strategy(
1018 &mut self,
1019 context: &lean_ctx::core::execution_lifecycle::TaskContext,
1020 ) -> Result<lean_ctx::core::execution_lifecycle::StageDisposition, Self::Error> {
1021 let Some(PreparedCallResult::Ready(call)) = self.prepared.as_mut() else {
1022 return Ok(
1023 lean_ctx::core::execution_lifecycle::StageDisposition::Skipped(
1024 "admission returned a terminal result",
1025 ),
1026 );
1027 };
1028 *self.server.task_envelope.write().await = Some(context.envelope.clone());
1029 call.minimal = call.config.minimal_overhead_effective();
1030 if !call.machine_readable {
1033 let (task, project_root) = {
1034 let session = self.server.session.read().await;
1035 (
1036 session.task.as_ref().map(|task| task.description.clone()),
1037 session.project_root.clone(),
1038 )
1039 };
1040 if !crate::tools::autonomy::delivers_session_briefing(&call.name, call.args.as_ref()) {
1041 crate::tools::autonomy::apply_auto_load_packages_once(
1042 &self.server.autonomy,
1043 project_root.as_deref(),
1044 );
1045 return Ok(lean_ctx::core::execution_lifecycle::StageDisposition::Applied);
1046 }
1047 call.auto_context = lean_ctx::core::session::caller::scope(
1049 Some(self.server.session.instance()),
1050 || {
1051 crate::tools::autonomy::session_wakeup_briefing(
1052 &self.server.autonomy,
1053 &call.name,
1054 task.as_deref(),
1055 project_root.as_deref(),
1056 )
1057 },
1058 );
1059 }
1060 Ok(lean_ctx::core::execution_lifecycle::StageDisposition::Applied)
1061 }
1062
1063 async fn ask_autopilot(
1064 &mut self,
1065 context: &lean_ctx::core::execution_lifecycle::TaskContext,
1066 ) -> Result<lean_ctx::core::execution_lifecycle::StageDisposition, Self::Error> {
1067 if !context.entitlements.autopilot {
1068 if let Some(PreparedCallResult::Ready(call)) = self.prepared.as_mut() {
1069 call.kernel_handoff =
1070 lean_ctx::core::context_kernel::bridge::runtime::KernelPlanningHandoff::Suppressed;
1071 }
1072 return Ok(
1073 lean_ctx::core::execution_lifecycle::StageDisposition::Skipped("not entitled"),
1074 );
1075 }
1076 let Some(PreparedCallResult::Ready(call)) = self.prepared.as_mut() else {
1077 return Ok(
1078 lean_ctx::core::execution_lifecycle::StageDisposition::Skipped(
1079 "admission returned a terminal result",
1080 ),
1081 );
1082 };
1083 *self.server.task_envelope.write().await = Some(context.envelope.clone());
1084 call.decision_context = Some(context.clone());
1085 let (disposition, handoff) = kernel::plan(self.server, call, context).await;
1086 call.kernel_handoff = handoff;
1087 Ok(disposition)
1088 }
1089
1090 async fn dispatch_primitive(
1091 &mut self,
1092 context: &lean_ctx::core::execution_lifecycle::TaskContext,
1093 ) -> Result<
1094 (
1095 Self::Primitive,
1096 lean_ctx::core::execution_lifecycle::StageDisposition,
1097 ),
1098 Self::Error,
1099 > {
1100 match self.prepared.take().expect("MCP admission consumed once") {
1101 PreparedCallResult::Terminal(result, reason) => Ok((
1102 McpDispatched::Terminal(result, reason),
1103 lean_ctx::core::execution_lifecycle::StageDisposition::Applied,
1104 )),
1105 PreparedCallResult::Cached(result) => Ok((
1106 McpDispatched::Terminal(result, "response cache hit"),
1107 lean_ctx::core::execution_lifecycle::StageDisposition::Applied,
1108 )),
1109 PreparedCallResult::Ready(call) => {
1110 let (guard_name, guard_args) =
1114 crate::tools::registered::ctx_call::resolve(&call.name, call.args.as_ref())?;
1115 let audit_agent = self.server.audit_agent_id().await;
1116 if let Some(mut denied) =
1117 LeanCtxServer::guard_role_and_policy(&guard_name, &audit_agent).or_else(|| {
1118 LeanCtxServer::guard_egress(&guard_name, guard_args.as_ref(), &audit_agent)
1119 })
1120 {
1121 if crate::server::dynamic_tools::INTERNAL_HOST_TOOLS
1122 .contains(&guard_name.as_str())
1123 {
1124 denied = host_hook_refusal(denied);
1125 }
1126 return Ok((
1127 McpDispatched::Terminal(denied, "policy changed before dispatch"),
1128 lean_ctx::core::execution_lifecycle::StageDisposition::Applied,
1129 ));
1130 }
1131 let shell_command = (guard_name == "ctx_shell")
1134 .then(|| {
1135 guard_args
1136 .as_ref()
1137 .and_then(|args| args.get("command"))
1138 .and_then(Value::as_str)
1139 })
1140 .flatten();
1141 let execute_items = (guard_name == "ctx_execute")
1144 .then(|| {
1145 guard_args
1146 .as_ref()
1147 .map(crate::server::shell_approval::execute_items)
1148 })
1149 .flatten();
1150 let approval = match (shell_command, execute_items) {
1151 (Some(command), _) => Some(self.server.shell_approval(command).await),
1152 (None, Some(items)) => Some(self.server.execute_approval(&items).await),
1153 (None, None) => None,
1154 };
1155 let shell_grant: Arc<[String]> = match approval {
1156 Some(outcome) => match outcome {
1157 crate::server::shell_approval::Outcome::Granted(programs) => {
1158 Arc::from(programs)
1159 }
1160 crate::server::shell_approval::Outcome::Refused(message) => {
1161 return Ok((
1162 McpDispatched::Terminal(
1163 CallToolResult::error(vec![ContentBlock::text(message)]),
1164 "shell command not approved",
1165 ),
1166 lean_ctx::core::execution_lifecycle::StageDisposition::Applied,
1167 ));
1168 }
1169 crate::server::shell_approval::Outcome::NotNeeded => Arc::from(Vec::new()),
1170 },
1171 None => Arc::from(Vec::new()),
1172 };
1173 if crate::server::native_receipts::requested(&call.name, call.args.as_ref()) {
1174 self.native_receipt_capture = self
1175 .server
1176 .native_receipt_authority
1177 .as_ref()
1178 .map(|authority| {
1179 Arc::new(crate::server::native_receipts::NativeReceiptCapture::new(
1180 authority.clone(),
1181 context.envelope.clone(),
1182 ))
1183 });
1184 }
1185 let dispatch_start = std::time::Instant::now();
1186 let dispatch = super::pipeline::dispatch_primitive(
1187 self.server,
1188 &call.name,
1189 call.args.as_ref(),
1190 call.minimal,
1191 &call.args_fp,
1192 );
1193 let primitive = crate::server::shell_approval::SHELL_GRANT
1194 .scope(
1195 shell_grant,
1196 crate::server::native_receipts::NATIVE_RECEIPT_CAPTURE.scope(
1197 self.native_receipt_capture.clone(),
1198 lean_ctx::core::context_kernel::bridge::runtime::KERNEL_PLANNING_HANDOFF
1199 .scope(call.kernel_handoff.clone(), dispatch),
1200 ),
1201 )
1202 .await;
1203 self.server.record_tool_usage(
1204 &call.name,
1205 dispatch_start,
1206 match &primitive {
1207 Ok(primitive) => primitive.failure(),
1208 Err(error) => Some(super::pipeline::error_failure(error)),
1209 },
1210 );
1211 let primitive = primitive?;
1212 Ok((
1213 McpDispatched::Raw { call, primitive },
1214 lean_ctx::core::execution_lifecycle::StageDisposition::Applied,
1215 ))
1216 }
1217 }
1218 }
1219
1220 async fn reversible_post_process(
1221 &mut self,
1222 _context: &lean_ctx::core::execution_lifecycle::TaskContext,
1223 primitive: Self::Primitive,
1224 ) -> Result<
1225 (
1226 Self::Processed,
1227 lean_ctx::core::execution_lifecycle::StageDisposition,
1228 ),
1229 Self::Error,
1230 > {
1231 match primitive {
1232 McpDispatched::Terminal(result, reason) => Ok((
1233 McpProcessedCall {
1234 result,
1235 pipeline: None,
1236 cache_key: None,
1237 skip_reason: Some(reason),
1238 },
1239 lean_ctx::core::execution_lifecycle::StageDisposition::Skipped(reason),
1240 )),
1241 McpDispatched::Raw { call, primitive } => {
1242 let disposition = match &primitive {
1243 super::pipeline::McpPrimitive::Raw(_) => {
1244 lean_ctx::core::execution_lifecycle::StageDisposition::Applied
1245 }
1246 super::pipeline::McpPrimitive::Terminal(_, reason) => {
1247 lean_ctx::core::execution_lifecycle::StageDisposition::Skipped(reason)
1248 }
1249 };
1250 let mut processed = super::pipeline::reversible_post_process(
1251 self.server,
1252 &call.name,
1253 call.args.as_ref(),
1254 call.minimal,
1255 call.config,
1256 call.machine_readable,
1257 call.auto_context,
1258 call.throttle_warning,
1259 call.decision_context,
1260 primitive,
1261 )
1262 .await?;
1263 if self.native_receipt_capture.is_some() {
1264 processed.freeze_receipt_delivery();
1265 }
1266 Ok((
1267 McpProcessedCall {
1268 result: processed.result.clone(),
1269 pipeline: Some(processed),
1270 cache_key: call.cache_key,
1271 skip_reason: None,
1272 },
1273 disposition,
1274 ))
1275 }
1276 }
1277 }
1278
1279 async fn record_context_ir(
1280 &mut self,
1281 _context: &lean_ctx::core::execution_lifecycle::TaskContext,
1282 processed: &Self::Processed,
1283 ) -> Result<lean_ctx::core::execution_lifecycle::StageDisposition, Self::Error> {
1284 Ok(match processed.pipeline.as_ref() {
1285 Some(pipeline) => super::pipeline::record_context_ir(self.server, pipeline).await,
1286 None => lean_ctx::core::execution_lifecycle::StageDisposition::Skipped(
1287 processed.skip_reason.unwrap_or("terminal result"),
1288 ),
1289 })
1290 }
1291
1292 async fn record_ledger(
1293 &mut self,
1294 _context: &lean_ctx::core::execution_lifecycle::TaskContext,
1295 processed: &Self::Processed,
1296 ) -> Result<lean_ctx::core::execution_lifecycle::StageDisposition, Self::Error> {
1297 if let Some(capture) = &self.native_receipt_capture {
1298 self.native_receipt_metadata = Some(
1299 capture
1300 .publish(&processed.result)
1301 .map_err(|code| ErrorData::internal_error(code, None))?,
1302 );
1303 }
1304 Ok(match processed.pipeline.as_ref() {
1305 Some(pipeline) => super::pipeline::record_ledger(self.server, pipeline).await?,
1306 None => lean_ctx::core::execution_lifecycle::StageDisposition::Skipped(
1307 processed.skip_reason.unwrap_or("terminal result"),
1308 ),
1309 })
1310 }
1311
1312 async fn checkpoint(
1313 &mut self,
1314 _context: &lean_ctx::core::execution_lifecycle::TaskContext,
1315 output: &mut Self::Output,
1316 ) -> Result<lean_ctx::core::execution_lifecycle::StageDisposition, Self::Error> {
1317 let disposition =
1318 super::pipeline::record_checkpoint(self.server, self.checkpoint.take(), output).await;
1319 if let Some(key) = self.pending_cache_key.take() {
1320 let bindable = !lean_ctx::core::context_admission::stored::source_authority_required()
1324 || replay_lineage::bind_cache_entry(&cache_lineage_id(&key));
1325 if bindable && project_only_output() {
1329 cache_call_result(global_response_cache(), key, output);
1330 }
1331 }
1332 Ok(disposition)
1333 }
1334
1335 fn output_from_processed(&mut self, mut processed: Self::Processed) -> Self::Output {
1336 if let Some(pipeline) = processed.pipeline.as_mut() {
1337 self.checkpoint = pipeline.checkpoint.take();
1338 }
1339 self.pending_cache_key = processed.cache_key;
1340 if let Some(receipt) = self.native_receipt_metadata.take() {
1341 processed
1342 .result
1343 .meta
1344 .get_or_insert_with(MetaObject::new)
1345 .0
1346 .insert("canonical_receipt".into(), receipt);
1347 }
1348 processed.result
1349 }
1350
1351 fn observe(
1352 &self,
1353 result: &Result<
1354 Self::Output,
1355 lean_ctx::core::execution_lifecycle::LifecycleRunError<Self::Error>,
1356 >,
1357 ) -> lean_ctx::core::execution_lifecycle::CompletionObservation {
1358 let (output_tokens, success) = result.as_ref().map_or((0, false), |response| {
1359 (
1360 u64::try_from(format!("{response:?}").len() / 4).unwrap_or(u64::MAX),
1361 response.is_error != Some(true),
1362 )
1363 });
1364 lean_ctx::core::execution_lifecycle::CompletionObservation::tool_result(
1365 self.input_tokens,
1366 output_tokens,
1367 "mcp-tool",
1368 success,
1369 )
1370 }
1371}
1372
1373#[cfg(test)]
1374#[path = "guarded_tests.rs"]
1375mod tests;