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