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, 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
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<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        // 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/// Whether the output of the call in scope carries no outside origin (its
227/// ledger and marked parts are empty). Without a capture: unknown, so no.
228fn project_only_output() -> bool {
229    lean_ctx::core::context_admission::capture::current()
230        .is_some_and(|capture| capture.output_origins().is_empty())
231}
232
233/// The lineage record of a response-cache entry.
234fn 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    /// A call keeps the session instance it was bound to once its roots were
285    /// resolved, so a root change while it runs cannot redirect its late
286    /// writes into the next project's session (AP04-G1).
287    pub async fn call_tool_guarded(
288        &self,
289        request: CallToolRequestParams,
290    ) -> Result<CallToolResult, ErrorData> {
291        // One Workspace snapshot per call (#2120): every path check of this
292        // call sees one revision, and the next call sees a detached root gone.
293        let authority = lean_ctx::core::workspace_authority::current();
294        // One rights snapshot per call (process-v1 §9): the request's bound
295        // rights (shared daemon) or this process's LEAN_CTX_RIGHTS_FILE.
296        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        // Background cadence, counted here because this is the one point every
320        // call passes regardless of how it is served: guard denials and
321        // response-cache hits below return before `dispatch_and_post_process`.
322        // It previously hung off `call_count`, which only advances in
323        // `record_checkpoint` — skipped entirely when `minimal_overhead`
324        // (default true) is set. Between the two, the daily telemetry flush
325        // never ran in a default install. The first call sends the day's first
326        // batch, so installs whose sessions stay short are still counted; every
327        // tenth call persists counters and resends the grown day totals once
328        // the aggregate admits it (spacing and daily cap live there).
329        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            // The first and every hundredth call also record the running
335            // version, as the daemon's tick does.
336            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                // Establish client-root authority before early receipt/identity
353                // checks can return, as well as before lifecycle replay lookup.
354                self.resolve_roots_once().await;
355                self.session.pin();
356                let (project, session_id) = {
357                    let session = self.session.read().await;
358                    // The session this call serves: its taint is what the
359                    // release point extends and `session_taint()` reads
360                    // (pz21 §1.7).
361                    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                // One Decision Receipt per call: every admission inside this
366                // call (handler and its read workers) lands in this capture.
367                // It carries the call's ONE policy snapshot, taken here.
368                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                // Under a pack an error leaves as one fixed text. Replaced
384                // before the receipt, so the receipt and its observation name
385                // the bytes that actually leave (AP10-C3).
386                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                // Persisted already (write-through); the session state keeps
397                // the same taint for its own save and for later loads.
398                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                // The receipt is stored before the result leaves (G08).
418                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        // A withheld Hive tool (release switch off) answers one fixed line
429        // before any lifecycle, ledger or tool state is touched.
430        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            // Includes idempotent replay: stale signer admission is never bypassed.
440            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        // Semantic identity is the actual tool, while replay keys and dispatch
447        // retain the original wrapper request. Invalid envelopes still go
448        // through the normal lifecycle admission/error path.
449        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        // The replay key is not changed: it only names the lineage record the
523        // dispatch writes and a later replay admission re-authorizes.
524        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        // Both channels pass the release point: an error's message and data
573        // reach the host as surely as a result does (AP10-C1).
574        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        // Plan mode: extract interactionMode before arguments are consumed.
586        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        // ctx_call is a meta-dispatcher: the egress DLP and permission-
608        // inheritance gates below must inspect the INNER tool + arguments, or
609        // the universal invoker becomes a policy bypass (#1008 security pass).
610        // Role/rate/workflow gates for the inner tool already run inside the
611        // dispatch layer; these two ran only on the wrapper name before.
612        let inner_call = if name == "ctx_call" {
613            match crate::tools::registered::ctx_call::resolve_inner(args) {
614                Ok(inner) => Some(inner),
615                // Preserve dispatch's soft INVALID_PARAMS contract for clients
616                // that otherwise mistake an argument error for transport failure.
617                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        // A wrapper must not plan for a tool that direct admission forbids.
632        // Dispatch still rechecks the inner role/rate/workflow boundaries.
633        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        // Plan mode enforcement: update session state and notify on transitions.
655        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        // Plan mode guard: block non-readonly tools (including via ctx_call).
671        {
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        // Hive bus control (C5b): `hold`/`stop` pause Hive tools only,
689        // including via ctx_call; local tools are never gated. The agent id
690        // is copied out first so no lock is held across the file check.
691        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        // A maintainer vetoed this agent's lock or handoff (§3.7.4): its next
704        // ctx_tasks / ctx_agent call says so, once.
705        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        // #990: determine machine-readability *before* the once-per-session
729        // decorations below. A machine-readable invocation (e.g. ctx_outline
730        // format=json) must reach the client byte-exact and parseable, so every
731        // prose decoration and terse compression is suppressed and the pure
732        // pre-decoration body is restored at the end (see the `machine_readable`
733        // guard near the end of this function). Computing it here — not after
734        // dispatch — means such a call also never *consumes* a latched
735        // once-per-session flag (auto-context briefing, rules tip) whose prose
736        // we would then discard, so those surface on the next human-facing call.
737        //
738        // `ctx_call` is a meta-dispatcher: the contract belongs to its *inner*
739        // tool + inner arguments, not to ctx_call itself. Unwrap one level so
740        // JSON reached via the lazy `ctx_call` path (the default advertised
741        // surface, where ctx_outline is not a top-level tool) is just as
742        // byte-exact as a direct call. This also covers JSON error envelopes
743        // from the early rate-limit path, which the first-call auto-context
744        // briefing would otherwise corrupt.
745        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        // Skip the session wake-up briefing for machine-readable calls: the
752        // wake-up briefing latches `session_briefed` via compare-exchange, so calling
753        // it here would burn the once-per-session slot for a briefing we then
754        // throw away. Deferring keeps the briefing intact for the next call.
755        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                // A host hook shapes output of a native call the agent already
778                // made; repeating `cargo test` is not a lean-ctx loop (#2089).
779                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        // IDE permission inheritance: when enabled, mirror the host IDE's
831        // bash/read/edit/grep permission rules onto the matching lean-ctx tool so
832        // e.g. `ctx_shell` honors a `rm *: ask` rule instead of bypassing it.
833        // Gated on the cheap effective() check so the default (off) pays no lock
834        // cost on the hot path. Checks the ctx_call-unwrapped inner tool (#1008)
835        // so the invoker cannot side-step an IDE deny.
836        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        // A cached answer is egress too: never served while a policy pack or
865        // the sensitivity floor could withhold what it contains.
866        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            // Under protection a hit needs a lineage that re-admits now.
874            .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    /// Feed the daily telemetry aggregate. Only registered tools are counted,
904    /// under the registry's own name; calls stopped by a guard never reach
905    /// here and are not usage. A failure carries only its class and template.
906    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        // Replays revalidate admission only. Context gathering/planning already
1004        // belongs to the cached execution and must not run a second time.
1005        // A current admission denial wins; otherwise the stored output is
1006        // returned only while every source it was derived from is still
1007        // authorized. Loss of rights yields a typed denial — the completed
1008        // operation is never executed again.
1009        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        // The briefing goes only to `ctx_session` load/status; every other
1031        // result stays free of per-run session text (#498).
1032        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            // Built from this caller's own session instance only (I07/S03).
1048            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                // Context gathering can await locks/services after admission.
1111                // Recheck current tool and egress rules before any primitive,
1112                // including the inner tool carried by ctx_call, is dispatched.
1113                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                // A command refused only for unlisted programs is put to the
1132                // user here, after every policy check and before dispatch.
1133                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                // ctx_execute code in a language other than shell, likewise
1142                // (GHSA-rxv5-4c3x-g4q6).
1143                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            // Under protection a result is cached only together with the
1321            // lineage of the dispatch that produced it; unknown lineage is
1322            // not cached at all.
1323            let bindable = !lean_ctx::core::context_admission::stored::source_authority_required()
1324                || replay_lineage::bind_cache_entry(&cache_lineage_id(&key));
1325            // A cache hit is served without a dispatch, so nothing would
1326            // note its outside origins: such a result is never cached
1327            // (pz21 §1.5), in every mode.
1328            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;