Skip to main content

lean_ctx/tools/registered/
ctx_handoff.rs

1use rmcp::ErrorData;
2use rmcp::model::Tool;
3use serde_json::{Map, Value, json};
4
5use crate::server::tool_trait::{
6    McpTool, ToolContext, ToolOutput, get_bool, get_str, get_str_array,
7};
8use crate::tool_defs::tool_def;
9
10pub struct CtxHandoffTool;
11
12impl McpTool for CtxHandoffTool {
13    fn name(&self) -> &'static str {
14        "ctx_handoff"
15    }
16
17    fn tool_def(&self) -> Tool {
18        tool_def(
19            "ctx_handoff",
20            "Context Ledger Protocol (hashed, deterministic, local-first). Actions: create|show|list|pull|clear|export|import.",
21            json!({
22                "type": "object",
23                "properties": {
24                    "action": {
25                        "type": "string",
26                        "enum": ["create", "show", "list", "pull", "clear", "export", "import"],
27                        "description": "Operation to perform (default: list)"
28                    },
29                    "path": { "type": "string", "description": "Ledger file path (for show/pull/import)" },
30                    "paths": { "type": "array", "items": { "type": "string" }, "description": "Optional file paths for curated refs (for create/export)" },
31                    "format": { "type": "string", "description": "Output format (json|summary|a2a — a2a wraps the bundle in a spec-shaped A2A Task envelope)" },
32                    "write": { "type": "boolean", "description": "Write export to file" },
33                    "privacy": { "type": "string", "description": "Export privacy: redacted (default) | full (admin only)" },
34                    "filename": { "type": "string", "description": "Custom filename for export" },
35                    "apply_workflow": { "type": "boolean", "description": "For pull/import: apply workflow state (default: true)" },
36                    "apply_session": { "type": "boolean", "description": "For pull/import: apply session snapshot (default: true)" },
37                    "apply_knowledge": { "type": "boolean", "description": "For pull/import: import knowledge facts (default: true)" }
38                }
39            }),
40        )
41    }
42
43    fn handle(
44        &self,
45        args: &Map<String, Value>,
46        ctx: &ToolContext,
47    ) -> Result<ToolOutput, ErrorData> {
48        let action = get_str(args, "action").unwrap_or_else(|| "list".to_string());
49        let result = match action.as_str() {
50            "list" => handle_list(),
51            "clear" => handle_clear(),
52            "show" => handle_show(args, ctx)?,
53            "create" => handle_create(args, ctx)?,
54            "export" => handle_export(args, ctx)?,
55            "pull" => handle_pull(args, ctx)?,
56            "import" => handle_import(args, ctx)?,
57            _ => "Unknown action. Use: create, show, list, pull, clear, export, import".to_string(),
58        };
59
60        Ok(ToolOutput {
61            text: result,
62            original_tokens: 0,
63            saved_tokens: 0,
64            mode: Some(action),
65            path: None,
66            changed: false,
67            shell_outcome: None,
68        })
69    }
70}
71
72fn handle_list() -> String {
73    let items = crate::core::handoff_ledger::list_ledgers();
74    crate::tools::ctx_handoff::format_list(&items)
75}
76
77fn handle_clear() -> String {
78    let removed = crate::core::handoff_ledger::clear_ledgers().unwrap_or_default();
79    crate::tools::ctx_handoff::format_clear(removed)
80}
81
82fn handle_show(args: &Map<String, Value>, ctx: &ToolContext) -> Result<String, ErrorData> {
83    let path = get_str(args, "path")
84        .ok_or_else(|| ErrorData::invalid_params("path is required for action=show", None))?;
85    let path = ctx
86        .resolve_path_sync(&path)
87        .map_err(|e| ErrorData::invalid_params(e, None))?;
88    let ledger = crate::core::handoff_ledger::load_ledger(std::path::Path::new(&path))
89        .map_err(|e| ErrorData::internal_error(format!("load ledger: {e}"), None))?;
90    Ok(crate::tools::ctx_handoff::format_show(
91        std::path::Path::new(&path),
92        &ledger,
93    ))
94}
95
96fn resolve_curated_refs(
97    args: &Map<String, Value>,
98    ctx: &ToolContext,
99) -> Result<Vec<(String, String)>, ErrorData> {
100    let curated_paths = get_str_array(args, "paths").unwrap_or_default();
101    let mut curated_refs: Vec<(String, String)> = Vec::new();
102    if curated_paths.is_empty() {
103        return Ok(curated_refs);
104    }
105
106    let mut resolved: Vec<String> = Vec::new();
107    for p in curated_paths.into_iter().take(20) {
108        let abs = ctx
109            .resolve_path_sync(&p)
110            .map_err(|e| ErrorData::invalid_params(e, None))?;
111        resolved.push(abs);
112    }
113
114    let cache_handle = ctx
115        .cache
116        .as_ref()
117        .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
118    let Some(mut cache) = crate::server::bounded_lock::write(cache_handle, "ctx_handoff") else {
119        return Err(ErrorData::internal_error(
120            "cache busy (ctx_handoff) — retry in a moment",
121            None,
122        ));
123    };
124    for abs in &resolved {
125        let mode = if crate::tools::ctx_read::is_instruction_file(abs) {
126            "full"
127        } else {
128            "signatures"
129        };
130        let text =
131            crate::tools::ctx_read::handle_with_task(&mut cache, abs, mode, ctx.crp_mode, None);
132        curated_refs.push((abs.clone(), text));
133    }
134
135    Ok(curated_refs)
136}
137
138fn handle_create(args: &Map<String, Value>, ctx: &ToolContext) -> Result<String, ErrorData> {
139    let curated_refs = resolve_curated_refs(args, ctx)?;
140
141    let session_handle = ctx
142        .session
143        .as_ref()
144        .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
145    let session = { session_handle.blocking_read().clone() };
146    let active_intent = session.active_structured_intent.clone();
147
148    let tool_calls = ctx
149        .tool_calls
150        .as_ref()
151        .map(|tc| tc.blocking_read().clone())
152        .unwrap_or_default();
153    let workflow = ctx
154        .workflow
155        .as_ref()
156        .map(|w| w.blocking_read().clone())
157        .unwrap_or_default();
158    let agent_id = ctx
159        .agent_id
160        .as_ref()
161        .map(|a| a.blocking_read().clone())
162        .unwrap_or_default();
163    let client_name = ctx
164        .client_name
165        .as_ref()
166        .map(|c| c.blocking_read().clone())
167        .unwrap_or_default();
168    let project_root = session.project_root.clone();
169
170    let (ledger, path) = crate::core::handoff_ledger::create_ledger(
171        crate::core::handoff_ledger::CreateLedgerInput {
172            agent_id,
173            client_name: Some(client_name),
174            project_root,
175            session,
176            tool_calls,
177            workflow,
178            curated_refs,
179        },
180    )
181    .map_err(|e| ErrorData::internal_error(format!("create ledger: {e}"), None))?;
182
183    let ctx_ledger_handle = ctx
184        .ledger
185        .as_ref()
186        .ok_or_else(|| ErrorData::internal_error("ledger not available", None))?;
187    let ctx_ledger = ctx_ledger_handle.blocking_read();
188    let package = crate::core::handoff_ledger::HandoffPackage::build(
189        ledger.clone(),
190        active_intent.as_ref(),
191        if ctx_ledger.entries.is_empty() {
192            None
193        } else {
194            Some(&*ctx_ledger)
195        },
196    );
197    drop(ctx_ledger);
198
199    let mut output = crate::tools::ctx_handoff::format_created(&path, &ledger);
200    let compact = package.format_compact();
201    if !compact.is_empty() {
202        output.push_str("\n\n");
203        output.push_str(&compact);
204    }
205
206    Ok(output)
207}
208
209fn handle_export(args: &Map<String, Value>, ctx: &ToolContext) -> Result<String, ErrorData> {
210    let curated_refs = resolve_curated_refs(args, ctx)?;
211
212    let session_handle = ctx
213        .session
214        .as_ref()
215        .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
216    let session = { session_handle.blocking_read().clone() };
217
218    let tool_calls = ctx
219        .tool_calls
220        .as_ref()
221        .map(|tc| tc.blocking_read().clone())
222        .unwrap_or_default();
223    let workflow = ctx
224        .workflow
225        .as_ref()
226        .map(|w| w.blocking_read().clone())
227        .unwrap_or_default();
228    let agent_id = ctx
229        .agent_id
230        .as_ref()
231        .map(|a| a.blocking_read().clone())
232        .unwrap_or_default();
233    let client_name = ctx
234        .client_name
235        .as_ref()
236        .map(|c| c.blocking_read().clone())
237        .unwrap_or_default();
238    let project_root = session.project_root.clone();
239
240    let (ledger, _ledger_path) = crate::core::handoff_ledger::create_ledger(
241        crate::core::handoff_ledger::CreateLedgerInput {
242            agent_id,
243            client_name: Some(client_name),
244            project_root: project_root.clone(),
245            session,
246            tool_calls,
247            workflow,
248            curated_refs,
249        },
250    )
251    .map_err(|e| ErrorData::internal_error(format!("create ledger: {e}"), None))?;
252
253    let privacy = crate::core::handoff_transfer_bundle::BundlePrivacyV1::parse(
254        get_str(args, "privacy").as_deref(),
255    );
256    if privacy == crate::core::handoff_transfer_bundle::BundlePrivacyV1::Full
257        && crate::core::roles::active_role_name() != "admin"
258    {
259        return Ok("ERROR: privacy=full requires role 'admin'.".to_string());
260    }
261
262    let mut bundle = crate::core::handoff_transfer_bundle::build_bundle_v1(
263        ledger,
264        project_root.as_deref(),
265        privacy,
266    );
267    // Sign every export (GL #465) so receivers can verify origin + integrity.
268    // Signing failure (e.g. unwritable key dir) degrades to an unsigned bundle
269    // with a warning — export stays local-first, import-side policy decides.
270    let signer_id = ctx
271        .agent_id
272        .as_ref()
273        .and_then(|a| a.blocking_read().clone())
274        .filter(|id| !id.trim().is_empty())
275        .unwrap_or_else(|| crate::core::agent_identity::current_agent_id().to_string());
276    let sign_warning = crate::core::handoff_transfer_bundle::sign_bundle(&mut bundle, &signer_id)
277        .err()
278        .map(|e| format!("WARNING: bundle not signed: {e}"));
279    let json = crate::core::handoff_transfer_bundle::serialize_bundle_v1_pretty(&bundle)
280        .map_err(|e| ErrorData::internal_error(e, None))?;
281
282    let write = get_bool(args, "write").unwrap_or(false);
283    let format = get_str(args, "format").unwrap_or_else(|| {
284        if write || get_str(args, "path").is_some() || get_str(args, "filename").is_some() {
285            "summary".to_string()
286        } else {
287            "json".to_string()
288        }
289    });
290
291    let root = project_root.clone().unwrap_or_else(|| {
292        std::env::current_dir()
293            .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
294    });
295    let root_path = std::path::PathBuf::from(&root);
296
297    let mut written: Option<std::path::PathBuf> = None;
298    if write || get_str(args, "path").is_some() || get_str(args, "filename").is_some() {
299        let ts = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
300        let candidate = if let Some(p) = get_str(args, "path") {
301            let p = std::path::PathBuf::from(p);
302            if p.is_absolute() {
303                p
304            } else {
305                root_path.join(p)
306            }
307        } else if let Some(name) = get_str(args, "filename") {
308            root_path.join(".lean-ctx").join("proofs").join(name)
309        } else {
310            let session_id = bundle.ledger.session.id.clone();
311            root_path
312                .join(".lean-ctx")
313                .join("proofs")
314                .join(format!("handoff-transfer-bundle-v1_{session_id}_{ts}.json"))
315        };
316
317        let jailed = match crate::core::io_boundary::jail_and_check_path(
318            "ctx_handoff.export",
319            candidate.as_path(),
320            root_path.as_path(),
321        ) {
322            Ok((p, _warning)) => p,
323            Err(e) => return Ok(e),
324        };
325
326        if let Err(e) = crate::core::handoff_transfer_bundle::write_bundle_v1(&jailed, &json) {
327            return Ok(format!("Export write failed: {e}"));
328        }
329
330        let mut ev = crate::core::evidence_ledger::EvidenceLedgerV1::load();
331        let _ = ev.record_artifact_file(
332            "proof:handoff-transfer-bundle-v1",
333            &jailed,
334            chrono::Utc::now(),
335        );
336        let _ = ev.save();
337
338        written = Some(jailed);
339    }
340
341    let out = match format.as_str() {
342        // The structured formats (json/a2a) stay machine-parseable: signature
343        // state is visible in the payload itself. Only the human summary
344        // carries the explicit signing line.
345        "summary" => {
346            let mut s = crate::tools::ctx_handoff::format_exported(
347                written.as_deref(),
348                bundle.schema_version,
349                json.len(),
350                &bundle.privacy,
351            );
352            match sign_warning.as_deref() {
353                Some(w) => {
354                    s.push('\n');
355                    s.push_str(w);
356                }
357                None => {
358                    s.push_str(&format!("\n signed_by: {signer_id}"));
359                }
360            }
361            s
362        }
363        // A2A envelope (GL#449): spec-shaped Task object a foreign agent can
364        // parse without knowing the lean-ctx bundle format.
365        "a2a" => {
366            let task = crate::core::a2a::transfer::wrap_bundle_as_a2a_task(&bundle)
367                .map_err(|e| ErrorData::internal_error(e, None))?;
368            serde_json::to_string_pretty(&task)
369                .map_err(|e| ErrorData::internal_error(format!("a2a serialization: {e}"), None))?
370        }
371        _ => {
372            if let Some(p) = written.as_deref() {
373                format!("{json}\n\npath: {}", p.display())
374            } else {
375                json
376            }
377        }
378    };
379
380    Ok(out)
381}
382
383fn handle_pull(args: &Map<String, Value>, ctx: &ToolContext) -> Result<String, ErrorData> {
384    let path = get_str(args, "path")
385        .ok_or_else(|| ErrorData::invalid_params("path is required for action=pull", None))?;
386    let path = ctx
387        .resolve_path_sync(&path)
388        .map_err(|e| ErrorData::invalid_params(e, None))?;
389    let ledger = crate::core::handoff_ledger::load_ledger(std::path::Path::new(&path))
390        .map_err(|e| ErrorData::internal_error(format!("load ledger: {e}"), None))?;
391
392    let apply_workflow = get_bool(args, "apply_workflow").unwrap_or(true);
393    let apply_session = get_bool(args, "apply_session").unwrap_or(true);
394    let apply_knowledge = get_bool(args, "apply_knowledge").unwrap_or(true);
395
396    if apply_workflow && let Some(wf_lock) = ctx.workflow.as_ref() {
397        let mut wf = wf_lock.blocking_write();
398        if ledger
399            .workflow
400            .as_ref()
401            .is_some_and(|r| r.current == "done")
402        {
403            *wf = None;
404        } else {
405            wf.clone_from(&ledger.workflow);
406        }
407    }
408
409    if apply_session {
410        let session_handle = ctx
411            .session
412            .as_ref()
413            .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
414        let mut session = session_handle.blocking_write();
415        if let Some(t) = ledger.session.task.as_deref() {
416            session.set_task(t, None);
417        }
418        for d in &ledger.session.decisions {
419            session.add_decision(d, None);
420        }
421        for f in &ledger.session.findings {
422            session.add_finding(None, None, f);
423        }
424        session.next_steps.clone_from(&ledger.session.next_steps);
425        let _ = session.save();
426    }
427
428    let (knowledge_imported, contradictions) = if apply_knowledge {
429        import_knowledge_from_ledger(ctx, &ledger)?
430    } else {
431        (0, 0)
432    };
433
434    let lines = [
435        "ctx_handoff pull".to_string(),
436        format!(" path: {path}"),
437        format!(" md5: {}", ledger.content_md5),
438        format!(" applied_workflow: {apply_workflow}"),
439        format!(" applied_session: {apply_session}"),
440        format!(" imported_knowledge: {knowledge_imported}"),
441        format!(" contradictions: {contradictions}"),
442    ];
443    Ok(lines.join("\n"))
444}
445
446fn handle_import(args: &Map<String, Value>, ctx: &ToolContext) -> Result<String, ErrorData> {
447    let path = get_str(args, "path")
448        .ok_or_else(|| ErrorData::invalid_params("path is required for action=import", None))?;
449
450    let project_root = ctx.project_root.clone();
451    let root_path = std::path::PathBuf::from(&project_root);
452
453    let candidate = {
454        let p = std::path::PathBuf::from(&path);
455        if p.is_absolute() {
456            p
457        } else {
458            root_path.join(p)
459        }
460    };
461    let jailed = match crate::core::io_boundary::jail_and_check_path(
462        "ctx_handoff.import",
463        candidate.as_path(),
464        root_path.as_path(),
465    ) {
466        Ok((p, _warning)) => p,
467        Err(e) => return Ok(e),
468    };
469
470    let bundle = match crate::core::handoff_transfer_bundle::read_bundle_v1(&jailed) {
471        Ok(b) => b,
472        Err(e) => return Ok(format!("Import failed: {e}")),
473    };
474
475    // Signature enforcement (GL #465): a bundle with broken/tampered signature
476    // material is rejected fail-closed; legacy unsigned bundles import with a
477    // warning; valid signatures surface the verified signer.
478    let signature_line = match crate::core::handoff_transfer_bundle::check_bundle_signature(&bundle)
479    {
480        crate::core::handoff_transfer_bundle::BundleSignatureStatus::Invalid(reason) => {
481            crate::core::audit_trail::record(crate::core::audit_trail::AuditEntryData {
482                agent_id: bundle
483                    .signer_agent_id
484                    .clone()
485                    .unwrap_or_else(|| "unknown".to_string()),
486                tool: "ctx_handoff".to_string(),
487                action: Some("import_signature_invalid".to_string()),
488                input_hash: crate::core::audit_trail::hash_input(args),
489                output_tokens: 0,
490                role: crate::core::roles::active_role_name(),
491                event_type: crate::core::audit_trail::AuditEventType::SecurityViolation,
492            });
493            return Ok(format!(
494                "IMPORT BLOCKED: bundle signature verification failed ({reason}).\n\
495                 The bundle was modified after signing or carries broken signature material.\n\
496                 Re-export it on the source agent (ctx_handoff export signs automatically)."
497            ));
498        }
499        crate::core::handoff_transfer_bundle::BundleSignatureStatus::Verified(signer) => {
500            format!(" signature: verified (signer={signer})")
501        }
502        crate::core::handoff_transfer_bundle::BundleSignatureStatus::Unsigned => {
503            " signature: WARNING unsigned legacy bundle (re-export to sign)".to_string()
504        }
505    };
506
507    let warning =
508        crate::core::handoff_transfer_bundle::project_identity_warning(&bundle, &project_root);
509
510    if let Some(ref w) = warning {
511        let source_hash = bundle
512            .project
513            .project_root_hash
514            .as_deref()
515            .unwrap_or("unknown");
516        let target_hash = crate::core::project_hash::hash_project_root(&project_root);
517        let role = crate::core::roles::active_role();
518        if !role.io.allow_cross_project_search {
519            let event = crate::core::memory_boundary::CrossProjectAuditEvent {
520                timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
521                event_type: crate::core::memory_boundary::CrossProjectEventType::Import,
522                source_project_hash: source_hash.to_string(),
523                target_project_hash: target_hash,
524                tool: "ctx_handoff".to_string(),
525                action: "import".to_string(),
526                facts_accessed: 0,
527                allowed: false,
528                policy_reason: format!("identity mismatch: {w}"),
529            };
530            crate::core::memory_boundary::record_audit_event(&event);
531            return Ok(format!(
532                "IMPORT BLOCKED: project identity mismatch. {w}\n\
533                 Set `io.allow_cross_project_search = true` in your role to allow cross-project imports."
534            ));
535        }
536    }
537
538    let schema_version = bundle.schema_version;
539    let ledger = bundle.ledger;
540
541    let apply_workflow = get_bool(args, "apply_workflow").unwrap_or(true);
542    let apply_session = get_bool(args, "apply_session").unwrap_or(true);
543    let apply_knowledge = get_bool(args, "apply_knowledge").unwrap_or(true);
544
545    if apply_workflow && let Some(wf_lock) = ctx.workflow.as_ref() {
546        let mut wf = wf_lock.blocking_write();
547        if ledger
548            .workflow
549            .as_ref()
550            .is_some_and(|r| r.current == "done")
551        {
552            *wf = None;
553        } else {
554            wf.clone_from(&ledger.workflow);
555        }
556    }
557
558    if apply_session {
559        let session_handle = ctx
560            .session
561            .as_ref()
562            .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
563        let mut session = session_handle.blocking_write();
564        if let Some(t) = ledger.session.task.as_deref() {
565            session.set_task(t, None);
566        }
567        for d in &ledger.session.decisions {
568            session.add_decision(d, None);
569        }
570        for f in &ledger.session.findings {
571            session.add_finding(None, None, f);
572        }
573        session.next_steps.clone_from(&ledger.session.next_steps);
574        let _ = session.save();
575    }
576
577    let (knowledge_imported, contradictions) = if apply_knowledge {
578        import_knowledge_from_ledger(ctx, &ledger)?
579    } else {
580        (0, 0)
581    };
582
583    Ok(crate::tools::ctx_handoff::format_imported(
584        jailed.as_path(),
585        schema_version,
586        knowledge_imported,
587        contradictions,
588        warning.as_deref(),
589        &signature_line,
590    ))
591}
592
593/// Shared knowledge import logic used by both pull and import actions.
594fn import_knowledge_from_ledger(
595    ctx: &ToolContext,
596    ledger: &crate::core::handoff_ledger::HandoffLedgerV1,
597) -> Result<(u32, u32), ErrorData> {
598    let project_root = ctx.project_root.clone();
599    let session_id = {
600        let session_handle = ctx
601            .session
602            .as_ref()
603            .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
604        let s = session_handle.blocking_read();
605        s.id.clone()
606    };
607
608    let policy = match crate::core::config::Config::load().memory_policy_effective() {
609        Ok(p) => p,
610        Err(e) => {
611            let path = crate::core::config::Config::path().map_or_else(
612                || "~/.lean-ctx/config.toml".to_string(),
613                |p| p.display().to_string(),
614            );
615            return Err(ErrorData::internal_error(
616                format!("Error: invalid memory policy: {e}\nFix: edit {path}"),
617                None,
618            ));
619        }
620    };
621
622    // Import under the cross-process lock (#326/#594): the daemon, the MCP
623    // server and a CLI handoff can all write knowledge concurrently.
624    let result =
625        crate::core::knowledge::ProjectKnowledge::mutate_locked(&project_root, |knowledge| {
626            let mut imported = 0u32;
627            let mut contradictions = 0u32;
628            for fact in &ledger.knowledge.facts {
629                let c = knowledge.remember(
630                    &fact.category,
631                    &fact.key,
632                    &fact.value,
633                    &session_id,
634                    fact.confidence,
635                    &policy,
636                );
637                if c.is_some() {
638                    contradictions += 1;
639                }
640                imported += 1;
641            }
642            let _ = knowledge.run_memory_lifecycle(&policy);
643            (imported, contradictions)
644        });
645
646    match result {
647        Ok((_, counts)) => Ok(counts),
648        Err(e) => Err(ErrorData::internal_error(
649            format!("knowledge import save failed: {e}"),
650            None,
651        )),
652    }
653}