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