Skip to main content

lean_ctx/tools/registered/
ctx_handoff.rs

1use rmcp::model::Tool;
2use rmcp::ErrorData;
3use serde_json::{json, Map, Value};
4
5use crate::server::tool_trait::{
6    get_bool, get_str, get_str_array, McpTool, ToolContext, ToolOutput,
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 {
397        if let Some(wf_lock) = ctx.workflow.as_ref() {
398            let mut wf = wf_lock.blocking_write();
399            if ledger
400                .workflow
401                .as_ref()
402                .is_some_and(|r| r.current == "done")
403            {
404                *wf = None;
405            } else {
406                wf.clone_from(&ledger.workflow);
407            }
408        }
409    }
410
411    if apply_session {
412        let session_handle = ctx
413            .session
414            .as_ref()
415            .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
416        let mut session = session_handle.blocking_write();
417        if let Some(t) = ledger.session.task.as_deref() {
418            session.set_task(t, None);
419        }
420        for d in &ledger.session.decisions {
421            session.add_decision(d, None);
422        }
423        for f in &ledger.session.findings {
424            session.add_finding(None, None, f);
425        }
426        session.next_steps.clone_from(&ledger.session.next_steps);
427        let _ = session.save();
428    }
429
430    let (knowledge_imported, contradictions) = if apply_knowledge {
431        import_knowledge_from_ledger(ctx, &ledger)?
432    } else {
433        (0, 0)
434    };
435
436    let lines = [
437        "ctx_handoff pull".to_string(),
438        format!(" path: {path}"),
439        format!(" md5: {}", ledger.content_md5),
440        format!(" applied_workflow: {apply_workflow}"),
441        format!(" applied_session: {apply_session}"),
442        format!(" imported_knowledge: {knowledge_imported}"),
443        format!(" contradictions: {contradictions}"),
444    ];
445    Ok(lines.join("\n"))
446}
447
448fn handle_import(args: &Map<String, Value>, ctx: &ToolContext) -> Result<String, ErrorData> {
449    let path = get_str(args, "path")
450        .ok_or_else(|| ErrorData::invalid_params("path is required for action=import", None))?;
451
452    let project_root = ctx.project_root.clone();
453    let root_path = std::path::PathBuf::from(&project_root);
454
455    let candidate = {
456        let p = std::path::PathBuf::from(&path);
457        if p.is_absolute() {
458            p
459        } else {
460            root_path.join(p)
461        }
462    };
463    let jailed = match crate::core::io_boundary::jail_and_check_path(
464        "ctx_handoff.import",
465        candidate.as_path(),
466        root_path.as_path(),
467    ) {
468        Ok((p, _warning)) => p,
469        Err(e) => return Ok(e),
470    };
471
472    let bundle = match crate::core::handoff_transfer_bundle::read_bundle_v1(&jailed) {
473        Ok(b) => b,
474        Err(e) => return Ok(format!("Import failed: {e}")),
475    };
476
477    // Signature enforcement (GL #465): a bundle with broken/tampered signature
478    // material is rejected fail-closed; legacy unsigned bundles import with a
479    // warning; valid signatures surface the verified signer.
480    let signature_line = match crate::core::handoff_transfer_bundle::check_bundle_signature(&bundle)
481    {
482        crate::core::handoff_transfer_bundle::BundleSignatureStatus::Invalid(reason) => {
483            crate::core::audit_trail::record(crate::core::audit_trail::AuditEntryData {
484                agent_id: bundle
485                    .signer_agent_id
486                    .clone()
487                    .unwrap_or_else(|| "unknown".to_string()),
488                tool: "ctx_handoff".to_string(),
489                action: Some("import_signature_invalid".to_string()),
490                input_hash: crate::core::audit_trail::hash_input(args),
491                output_tokens: 0,
492                role: crate::core::roles::active_role_name(),
493                event_type: crate::core::audit_trail::AuditEventType::SecurityViolation,
494            });
495            return Ok(format!(
496                "IMPORT BLOCKED: bundle signature verification failed ({reason}).\n\
497                 The bundle was modified after signing or carries broken signature material.\n\
498                 Re-export it on the source agent (ctx_handoff export signs automatically)."
499            ));
500        }
501        crate::core::handoff_transfer_bundle::BundleSignatureStatus::Verified(signer) => {
502            format!(" signature: verified (signer={signer})")
503        }
504        crate::core::handoff_transfer_bundle::BundleSignatureStatus::Unsigned => {
505            " signature: WARNING unsigned legacy bundle (re-export to sign)".to_string()
506        }
507    };
508
509    let warning =
510        crate::core::handoff_transfer_bundle::project_identity_warning(&bundle, &project_root);
511
512    if let Some(ref w) = warning {
513        let source_hash = bundle
514            .project
515            .project_root_hash
516            .as_deref()
517            .unwrap_or("unknown");
518        let target_hash = crate::core::project_hash::hash_project_root(&project_root);
519        let role = crate::core::roles::active_role();
520        if !role.io.allow_cross_project_search {
521            let event = crate::core::memory_boundary::CrossProjectAuditEvent {
522                timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
523                event_type: crate::core::memory_boundary::CrossProjectEventType::Import,
524                source_project_hash: source_hash.to_string(),
525                target_project_hash: target_hash,
526                tool: "ctx_handoff".to_string(),
527                action: "import".to_string(),
528                facts_accessed: 0,
529                allowed: false,
530                policy_reason: format!("identity mismatch: {w}"),
531            };
532            crate::core::memory_boundary::record_audit_event(&event);
533            return Ok(format!(
534                "IMPORT BLOCKED: project identity mismatch. {w}\n\
535                 Set `io.allow_cross_project_search = true` in your role to allow cross-project imports."
536            ));
537        }
538    }
539
540    let schema_version = bundle.schema_version;
541    let ledger = bundle.ledger;
542
543    let apply_workflow = get_bool(args, "apply_workflow").unwrap_or(true);
544    let apply_session = get_bool(args, "apply_session").unwrap_or(true);
545    let apply_knowledge = get_bool(args, "apply_knowledge").unwrap_or(true);
546
547    if apply_workflow {
548        if let Some(wf_lock) = ctx.workflow.as_ref() {
549            let mut wf = wf_lock.blocking_write();
550            if ledger
551                .workflow
552                .as_ref()
553                .is_some_and(|r| r.current == "done")
554            {
555                *wf = None;
556            } else {
557                wf.clone_from(&ledger.workflow);
558            }
559        }
560    }
561
562    if apply_session {
563        let session_handle = ctx
564            .session
565            .as_ref()
566            .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
567        let mut session = session_handle.blocking_write();
568        if let Some(t) = ledger.session.task.as_deref() {
569            session.set_task(t, None);
570        }
571        for d in &ledger.session.decisions {
572            session.add_decision(d, None);
573        }
574        for f in &ledger.session.findings {
575            session.add_finding(None, None, f);
576        }
577        session.next_steps.clone_from(&ledger.session.next_steps);
578        let _ = session.save();
579    }
580
581    let (knowledge_imported, contradictions) = if apply_knowledge {
582        import_knowledge_from_ledger(ctx, &ledger)?
583    } else {
584        (0, 0)
585    };
586
587    Ok(crate::tools::ctx_handoff::format_imported(
588        jailed.as_path(),
589        schema_version,
590        knowledge_imported,
591        contradictions,
592        warning.as_deref(),
593        &signature_line,
594    ))
595}
596
597/// Shared knowledge import logic used by both pull and import actions.
598fn import_knowledge_from_ledger(
599    ctx: &ToolContext,
600    ledger: &crate::core::handoff_ledger::HandoffLedgerV1,
601) -> Result<(u32, u32), ErrorData> {
602    let project_root = ctx.project_root.clone();
603    let session_id = {
604        let session_handle = ctx
605            .session
606            .as_ref()
607            .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
608        let s = session_handle.blocking_read();
609        s.id.clone()
610    };
611
612    let policy = match crate::core::config::Config::load().memory_policy_effective() {
613        Ok(p) => p,
614        Err(e) => {
615            let path = crate::core::config::Config::path().map_or_else(
616                || "~/.lean-ctx/config.toml".to_string(),
617                |p| p.display().to_string(),
618            );
619            return Err(ErrorData::internal_error(
620                format!("Error: invalid memory policy: {e}\nFix: edit {path}"),
621                None,
622            ));
623        }
624    };
625
626    // Import under the cross-process lock (#326/#594): the daemon, the MCP
627    // server and a CLI handoff can all write knowledge concurrently.
628    let result =
629        crate::core::knowledge::ProjectKnowledge::mutate_locked(&project_root, |knowledge| {
630            let mut imported = 0u32;
631            let mut contradictions = 0u32;
632            for fact in &ledger.knowledge.facts {
633                let c = knowledge.remember(
634                    &fact.category,
635                    &fact.key,
636                    &fact.value,
637                    &session_id,
638                    fact.confidence,
639                    &policy,
640                );
641                if c.is_some() {
642                    contradictions += 1;
643                }
644                imported += 1;
645            }
646            let _ = knowledge.run_memory_lifecycle(&policy);
647            (imported, contradictions)
648        });
649
650    match result {
651        Ok((_, counts)) => Ok(counts),
652        Err(e) => Err(ErrorData::internal_error(
653            format!("knowledge import save failed: {e}"),
654            None,
655        )),
656    }
657}