Skip to main content

lean_ctx_server/server/call_tool/
guarded.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use 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
67/// A gate refusal (role, policy, workflow, loop throttle, budget) on a call to
68/// an internal host hook. The host replaces a native tool's output with the
69/// hook's success body, so the refusal must read as an error: the host then
70/// keeps the original output instead of the block message (#2089).
71fn host_hook_refusal(mut result: CallToolResult) -> CallToolResult {
72    result.is_error = Some(true);
73    result
74}
75
76/// A loop-throttle block as a tool error carrying the detector's message,
77/// like a role or policy denial: the call was refused, not answered.
78/// `None` when the call is not blocked.
79fn 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        // A new lifecycle task must execute and bind its own native plan.
127        return None;
128    }
129    // A cached response does not carry the Workspace revision it was served
130    // under; a detached root must be gone on the next call (#2120).
131    if lean_ctx::core::workspace_authority::is_configured() {
132        return None;
133    }
134    // Semantic results include mutable kernel context. Keep its underlying
135    // search caches, but plan/reapply context before caching the whole response.
136    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    // The answer is a function of the files too: an edit between two identical
152    // calls must miss, or the model sees its own change undone and re-reads
153    // the file to resolve the contradiction (benchmark replay: `ctx_search`
154    // after `ctx_patch` returned the pre-edit matches). The stat-only corpus
155    // signature notices edits by any tool; a scan root it cannot fingerprint
156    // is not cached.
157    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
181/// Stat-only signature of the tree a cacheable call scans: its `path`
182/// argument resolved against the project root, else the project root.
183fn 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        // Nothing on disk to drift; the path is part of the key already.
196        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    // The response cache keeps whole tool results without their sources.
203    // Where stored output needs a source authority (policy pack, governed or
204    // sovereign gateway) it is neither read nor filled: an answer cached
205    // before the mode was tightened is not served afterwards.
206    !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
212/// Under protection the response cache is not closed but BOUND: an entry is
213/// stored only together with the lineage of the dispatch that produced it
214/// (a complete captured source set, or an attested source-free output) and
215/// served only while that lineage re-admits under the current policy, role
216/// and project and the gateway policy is unchanged
217/// (`replay_lineage::{bind_cache_entry, cache_entry_current}`). The other
218/// reasons that close the cache — sensitivity floor, redaction switched off
219/// for the role — close the bound cache as well.
220fn 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
226/// The lineage record of a response-cache entry.
227fn 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    /// A call keeps the session instance it was bound to once its roots were
278    /// resolved, so a root change while it runs cannot redirect its late
279    /// writes into the next project's session (AP04-G1).
280    pub async fn call_tool_guarded(
281        &self,
282        request: CallToolRequestParams,
283    ) -> Result<CallToolResult, ErrorData> {
284        // One Workspace snapshot per call (#2120): every path check of this
285        // call sees one revision, and the next call sees a detached root gone.
286        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        // Background cadence, counted here because this is the one point every
307        // call passes regardless of how it is served: guard denials and
308        // response-cache hits below return before `dispatch_and_post_process`.
309        // It previously hung off `call_count`, which only advances in
310        // `record_checkpoint` — skipped entirely when `minimal_overhead`
311        // (default true) is set. Between the two, the daily telemetry flush
312        // never ran in a default install. The first call sends the day's first
313        // batch, so installs whose sessions stay short are still counted; every
314        // tenth call persists counters and resends the grown day totals once
315        // the aggregate admits it (spacing and daily cap live there).
316        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            // The first and every hundredth call also record the running
322            // version, as the daemon's tick does.
323            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                // Establish client-root authority before early receipt/identity
340                // checks can return, as well as before lifecycle replay lookup.
341                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                // One Decision Receipt per call: every admission inside this
346                // call (handler and its read workers) lands in this capture.
347                // It carries the call's ONE policy snapshot, taken here.
348                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                // Under a pack an error leaves as one fixed text. Replaced
360                // before the receipt, so the receipt and its observation name
361                // the bytes that actually leave (AP10-C3).
362                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                // The receipt is stored before the result leaves (G08).
373                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        // A withheld Hive tool (release switch off) answers one fixed line
384        // before any lifecycle, ledger or tool state is touched.
385        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            // Includes idempotent replay: stale signer admission is never bypassed.
395            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        // Semantic identity is the actual tool, while replay keys and dispatch
402        // retain the original wrapper request. Invalid envelopes still go
403        // through the normal lifecycle admission/error path.
404        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        // The replay key is not changed: it only names the lineage record the
478        // dispatch writes and a later replay admission re-authorizes.
479        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        // Both channels pass the release point: an error's message and data
528        // reach the host as surely as a result does (AP10-C1).
529        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        // Plan mode: extract interactionMode before arguments are consumed.
541        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        // ctx_call is a meta-dispatcher: the egress DLP and permission-
563        // inheritance gates below must inspect the INNER tool + arguments, or
564        // the universal invoker becomes a policy bypass (#1008 security pass).
565        // Role/rate/workflow gates for the inner tool already run inside the
566        // dispatch layer; these two ran only on the wrapper name before.
567        let inner_call = if name == "ctx_call" {
568            match crate::tools::registered::ctx_call::resolve_inner(args) {
569                Ok(inner) => Some(inner),
570                // Preserve dispatch's soft INVALID_PARAMS contract for clients
571                // that otherwise mistake an argument error for transport failure.
572                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        // A wrapper must not plan for a tool that direct admission forbids.
587        // Dispatch still rechecks the inner role/rate/workflow boundaries.
588        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        // Plan mode enforcement: update session state and notify on transitions.
604        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        // Plan mode guard: block non-readonly tools (including via ctx_call).
620        {
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        // Hive bus control (C5b): `hold`/`stop` pause Hive tools only,
638        // including via ctx_call; local tools are never gated. The agent id
639        // is copied out first so no lock is held across the file check.
640        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        // A maintainer vetoed this agent's lock or handoff (§3.7.4): its next
653        // ctx_tasks / ctx_agent call says so, once.
654        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        // #990: determine machine-readability *before* the once-per-session
671        // decorations below. A machine-readable invocation (e.g. ctx_outline
672        // format=json) must reach the client byte-exact and parseable, so every
673        // prose decoration and terse compression is suppressed and the pure
674        // pre-decoration body is restored at the end (see the `machine_readable`
675        // guard near the end of this function). Computing it here — not after
676        // dispatch — means such a call also never *consumes* a latched
677        // once-per-session flag (auto-context briefing, rules tip) whose prose
678        // we would then discard, so those surface on the next human-facing call.
679        //
680        // `ctx_call` is a meta-dispatcher: the contract belongs to its *inner*
681        // tool + inner arguments, not to ctx_call itself. Unwrap one level so
682        // JSON reached via the lazy `ctx_call` path (the default advertised
683        // surface, where ctx_outline is not a top-level tool) is just as
684        // byte-exact as a direct call. This also covers JSON error envelopes
685        // from the early rate-limit path, which the first-call auto-context
686        // briefing would otherwise corrupt.
687        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        // Skip the session wake-up briefing for machine-readable calls: the
694        // wake-up briefing latches `session_briefed` via compare-exchange, so calling
695        // it here would burn the once-per-session slot for a briefing we then
696        // throw away. Deferring keeps the briefing intact for the next call.
697        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                // A host hook shapes output of a native call the agent already
720                // made; repeating `cargo test` is not a lean-ctx loop (#2089).
721                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        // IDE permission inheritance: when enabled, mirror the host IDE's
773        // bash/read/edit/grep permission rules onto the matching lean-ctx tool so
774        // e.g. `ctx_shell` honors a `rm *: ask` rule instead of bypassing it.
775        // Gated on the cheap effective() check so the default (off) pays no lock
776        // cost on the hot path. Checks the ctx_call-unwrapped inner tool (#1008)
777        // so the invoker cannot side-step an IDE deny.
778        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        // A cached answer is egress too: never served while a policy pack or
807        // the sensitivity floor could withhold what it contains.
808        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            // Under protection a hit needs a lineage that re-admits now.
816            .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    /// Feed the daily telemetry aggregate. Only registered tools are counted,
846    /// under the registry's own name; calls stopped by a guard never reach
847    /// here and are not usage. A failure carries only its class and template.
848    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        // Replays revalidate admission only. Context gathering/planning already
946        // belongs to the cached execution and must not run a second time.
947        // A current admission denial wins; otherwise the stored output is
948        // returned only while every source it was derived from is still
949        // authorized. Loss of rights yields a typed denial — the completed
950        // operation is never executed again.
951        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        // The briefing goes only to `ctx_session` load/status; every other
973        // result stays free of per-run session text (#498).
974        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            // Built from this caller's own session instance only (I07/S03).
990            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                // Context gathering can await locks/services after admission.
1053                // Recheck current tool and egress rules before any primitive,
1054                // including the inner tool carried by ctx_call, is dispatched.
1055                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                // A command refused only for unlisted programs is put to the
1074                // user here, after every policy check and before dispatch.
1075                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                // ctx_execute code in a language other than shell, likewise
1084                // (GHSA-rxv5-4c3x-g4q6).
1085                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            // Under protection a result is cached only together with the
1263            // lineage of the dispatch that produced it; unknown lineage is
1264            // not cached at all.
1265            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;