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