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)" },
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        })
68    }
69}
70
71fn handle_list() -> String {
72    let items = crate::core::handoff_ledger::list_ledgers();
73    crate::tools::ctx_handoff::format_list(&items)
74}
75
76fn handle_clear() -> String {
77    let removed = crate::core::handoff_ledger::clear_ledgers().unwrap_or_default();
78    crate::tools::ctx_handoff::format_clear(removed)
79}
80
81fn handle_show(args: &Map<String, Value>, ctx: &ToolContext) -> Result<String, ErrorData> {
82    let path = get_str(args, "path")
83        .ok_or_else(|| ErrorData::invalid_params("path is required for action=show", None))?;
84    let path = ctx
85        .resolve_path_sync(&path)
86        .map_err(|e| ErrorData::invalid_params(e, None))?;
87    let ledger = crate::core::handoff_ledger::load_ledger(std::path::Path::new(&path))
88        .map_err(|e| ErrorData::internal_error(format!("load ledger: {e}"), None))?;
89    Ok(crate::tools::ctx_handoff::format_show(
90        std::path::Path::new(&path),
91        &ledger,
92    ))
93}
94
95fn resolve_curated_refs(
96    args: &Map<String, Value>,
97    ctx: &ToolContext,
98) -> Result<Vec<(String, String)>, ErrorData> {
99    let curated_paths = get_str_array(args, "paths").unwrap_or_default();
100    let mut curated_refs: Vec<(String, String)> = Vec::new();
101    if curated_paths.is_empty() {
102        return Ok(curated_refs);
103    }
104
105    let mut resolved: Vec<String> = Vec::new();
106    for p in curated_paths.into_iter().take(20) {
107        let abs = ctx
108            .resolve_path_sync(&p)
109            .map_err(|e| ErrorData::invalid_params(e, None))?;
110        resolved.push(abs);
111    }
112
113    let cache_handle = ctx.cache.as_ref().unwrap();
114    let Some(mut cache) = crate::server::bounded_lock::write(cache_handle, "ctx_handoff") else {
115        return Err(ErrorData::internal_error(
116            "cache busy (ctx_handoff) — retry in a moment",
117            None,
118        ));
119    };
120    for abs in &resolved {
121        let mode = if crate::tools::ctx_read::is_instruction_file(abs) {
122            "full"
123        } else {
124            "signatures"
125        };
126        let text =
127            crate::tools::ctx_read::handle_with_task(&mut cache, abs, mode, ctx.crp_mode, None);
128        curated_refs.push((abs.clone(), text));
129    }
130
131    Ok(curated_refs)
132}
133
134fn handle_create(args: &Map<String, Value>, ctx: &ToolContext) -> Result<String, ErrorData> {
135    let curated_refs = resolve_curated_refs(args, ctx)?;
136
137    let session_handle = ctx.session.as_ref().unwrap();
138    let session = { session_handle.blocking_read().clone() };
139    let active_intent = session.active_structured_intent.clone();
140
141    let tool_calls = {
142        let tc = ctx.tool_calls.as_ref().unwrap().blocking_read();
143        tc.clone()
144    };
145    let workflow = { ctx.workflow.as_ref().unwrap().blocking_read().clone() };
146    let agent_id = { ctx.agent_id.as_ref().unwrap().blocking_read().clone() };
147    let client_name = { ctx.client_name.as_ref().unwrap().blocking_read().clone() };
148    let project_root = session.project_root.clone();
149
150    let (ledger, path) = crate::core::handoff_ledger::create_ledger(
151        crate::core::handoff_ledger::CreateLedgerInput {
152            agent_id,
153            client_name: Some(client_name),
154            project_root,
155            session,
156            tool_calls,
157            workflow,
158            curated_refs,
159        },
160    )
161    .map_err(|e| ErrorData::internal_error(format!("create ledger: {e}"), None))?;
162
163    let ctx_ledger = ctx.ledger.as_ref().unwrap().blocking_read();
164    let package = crate::core::handoff_ledger::HandoffPackage::build(
165        ledger.clone(),
166        active_intent.as_ref(),
167        if ctx_ledger.entries.is_empty() {
168            None
169        } else {
170            Some(&*ctx_ledger)
171        },
172    );
173    drop(ctx_ledger);
174
175    let mut output = crate::tools::ctx_handoff::format_created(&path, &ledger);
176    let compact = package.format_compact();
177    if !compact.is_empty() {
178        output.push_str("\n\n");
179        output.push_str(&compact);
180    }
181
182    Ok(output)
183}
184
185fn handle_export(args: &Map<String, Value>, ctx: &ToolContext) -> Result<String, ErrorData> {
186    let curated_refs = resolve_curated_refs(args, ctx)?;
187
188    let session_handle = ctx.session.as_ref().unwrap();
189    let session = { session_handle.blocking_read().clone() };
190
191    let tool_calls = {
192        let tc = ctx.tool_calls.as_ref().unwrap().blocking_read();
193        tc.clone()
194    };
195    let workflow = { ctx.workflow.as_ref().unwrap().blocking_read().clone() };
196    let agent_id = { ctx.agent_id.as_ref().unwrap().blocking_read().clone() };
197    let client_name = { ctx.client_name.as_ref().unwrap().blocking_read().clone() };
198    let project_root = session.project_root.clone();
199
200    let (ledger, _ledger_path) = crate::core::handoff_ledger::create_ledger(
201        crate::core::handoff_ledger::CreateLedgerInput {
202            agent_id,
203            client_name: Some(client_name),
204            project_root: project_root.clone(),
205            session,
206            tool_calls,
207            workflow,
208            curated_refs,
209        },
210    )
211    .map_err(|e| ErrorData::internal_error(format!("create ledger: {e}"), None))?;
212
213    let privacy = crate::core::handoff_transfer_bundle::BundlePrivacyV1::parse(
214        get_str(args, "privacy").as_deref(),
215    );
216    if privacy == crate::core::handoff_transfer_bundle::BundlePrivacyV1::Full
217        && crate::core::roles::active_role_name() != "admin"
218    {
219        return Ok("ERROR: privacy=full requires role 'admin'.".to_string());
220    }
221
222    let bundle = crate::core::handoff_transfer_bundle::build_bundle_v1(
223        ledger,
224        project_root.as_deref(),
225        privacy,
226    );
227    let json = crate::core::handoff_transfer_bundle::serialize_bundle_v1_pretty(&bundle)
228        .map_err(|e| ErrorData::internal_error(e, None))?;
229
230    let write = get_bool(args, "write").unwrap_or(false);
231    let format = get_str(args, "format").unwrap_or_else(|| {
232        if write || get_str(args, "path").is_some() || get_str(args, "filename").is_some() {
233            "summary".to_string()
234        } else {
235            "json".to_string()
236        }
237    });
238
239    let root = project_root.clone().unwrap_or_else(|| {
240        std::env::current_dir()
241            .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
242    });
243    let root_path = std::path::PathBuf::from(&root);
244
245    let mut written: Option<std::path::PathBuf> = None;
246    if write || get_str(args, "path").is_some() || get_str(args, "filename").is_some() {
247        let ts = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
248        let candidate = if let Some(p) = get_str(args, "path") {
249            let p = std::path::PathBuf::from(p);
250            if p.is_absolute() {
251                p
252            } else {
253                root_path.join(p)
254            }
255        } else if let Some(name) = get_str(args, "filename") {
256            root_path.join(".lean-ctx").join("proofs").join(name)
257        } else {
258            let session_id = bundle.ledger.session.id.clone();
259            root_path
260                .join(".lean-ctx")
261                .join("proofs")
262                .join(format!("handoff-transfer-bundle-v1_{session_id}_{ts}.json"))
263        };
264
265        let jailed = match crate::core::io_boundary::jail_and_check_path(
266            "ctx_handoff.export",
267            candidate.as_path(),
268            root_path.as_path(),
269        ) {
270            Ok((p, _warning)) => p,
271            Err(e) => return Ok(e),
272        };
273
274        if let Err(e) = crate::core::handoff_transfer_bundle::write_bundle_v1(&jailed, &json) {
275            return Ok(format!("Export write failed: {e}"));
276        }
277
278        let mut ev = crate::core::evidence_ledger::EvidenceLedgerV1::load();
279        let _ = ev.record_artifact_file(
280            "proof:handoff-transfer-bundle-v1",
281            &jailed,
282            chrono::Utc::now(),
283        );
284        let _ = ev.save();
285
286        written = Some(jailed);
287    }
288
289    let out = match format.as_str() {
290        "summary" => crate::tools::ctx_handoff::format_exported(
291            written.as_deref(),
292            bundle.schema_version,
293            json.len(),
294            &bundle.privacy,
295        ),
296        _ => {
297            if let Some(p) = written.as_deref() {
298                format!("{json}\n\npath: {}", p.display())
299            } else {
300                json
301            }
302        }
303    };
304
305    Ok(out)
306}
307
308fn handle_pull(args: &Map<String, Value>, ctx: &ToolContext) -> Result<String, ErrorData> {
309    let path = get_str(args, "path")
310        .ok_or_else(|| ErrorData::invalid_params("path is required for action=pull", None))?;
311    let path = ctx
312        .resolve_path_sync(&path)
313        .map_err(|e| ErrorData::invalid_params(e, None))?;
314    let ledger = crate::core::handoff_ledger::load_ledger(std::path::Path::new(&path))
315        .map_err(|e| ErrorData::internal_error(format!("load ledger: {e}"), None))?;
316
317    let apply_workflow = get_bool(args, "apply_workflow").unwrap_or(true);
318    let apply_session = get_bool(args, "apply_session").unwrap_or(true);
319    let apply_knowledge = get_bool(args, "apply_knowledge").unwrap_or(true);
320
321    if apply_workflow {
322        let mut wf = ctx.workflow.as_ref().unwrap().blocking_write();
323        // Never restore a terminal "done" workflow — it would block all tools
324        if ledger
325            .workflow
326            .as_ref()
327            .is_some_and(|r| r.current == "done")
328        {
329            *wf = None;
330        } else {
331            wf.clone_from(&ledger.workflow);
332        }
333    }
334
335    if apply_session {
336        let session_handle = ctx.session.as_ref().unwrap();
337        let mut session = session_handle.blocking_write();
338        if let Some(t) = ledger.session.task.as_deref() {
339            session.set_task(t, None);
340        }
341        for d in &ledger.session.decisions {
342            session.add_decision(d, None);
343        }
344        for f in &ledger.session.findings {
345            session.add_finding(None, None, f);
346        }
347        session.next_steps.clone_from(&ledger.session.next_steps);
348        let _ = session.save();
349    }
350
351    let (knowledge_imported, contradictions) = if apply_knowledge {
352        import_knowledge_from_ledger(ctx, &ledger)?
353    } else {
354        (0, 0)
355    };
356
357    let lines = [
358        "ctx_handoff pull".to_string(),
359        format!(" path: {path}"),
360        format!(" md5: {}", ledger.content_md5),
361        format!(" applied_workflow: {apply_workflow}"),
362        format!(" applied_session: {apply_session}"),
363        format!(" imported_knowledge: {knowledge_imported}"),
364        format!(" contradictions: {contradictions}"),
365    ];
366    Ok(lines.join("\n"))
367}
368
369fn handle_import(args: &Map<String, Value>, ctx: &ToolContext) -> Result<String, ErrorData> {
370    let path = get_str(args, "path")
371        .ok_or_else(|| ErrorData::invalid_params("path is required for action=import", None))?;
372
373    let project_root = ctx.project_root.clone();
374    let root_path = std::path::PathBuf::from(&project_root);
375
376    let candidate = {
377        let p = std::path::PathBuf::from(&path);
378        if p.is_absolute() {
379            p
380        } else {
381            root_path.join(p)
382        }
383    };
384    let jailed = match crate::core::io_boundary::jail_and_check_path(
385        "ctx_handoff.import",
386        candidate.as_path(),
387        root_path.as_path(),
388    ) {
389        Ok((p, _warning)) => p,
390        Err(e) => return Ok(e),
391    };
392
393    let bundle = match crate::core::handoff_transfer_bundle::read_bundle_v1(&jailed) {
394        Ok(b) => b,
395        Err(e) => return Ok(format!("Import failed: {e}")),
396    };
397
398    let warning =
399        crate::core::handoff_transfer_bundle::project_identity_warning(&bundle, &project_root);
400
401    if let Some(ref w) = warning {
402        let source_hash = bundle
403            .project
404            .project_root_hash
405            .as_deref()
406            .unwrap_or("unknown");
407        let target_hash = crate::core::project_hash::hash_project_root(&project_root);
408        let role = crate::core::roles::active_role();
409        if !role.io.allow_cross_project_search {
410            let event = crate::core::memory_boundary::CrossProjectAuditEvent {
411                timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
412                event_type: crate::core::memory_boundary::CrossProjectEventType::Import,
413                source_project_hash: source_hash.to_string(),
414                target_project_hash: target_hash,
415                tool: "ctx_handoff".to_string(),
416                action: "import".to_string(),
417                facts_accessed: 0,
418                allowed: false,
419                policy_reason: format!("identity mismatch: {w}"),
420            };
421            crate::core::memory_boundary::record_audit_event(&event);
422            return Ok(format!(
423                "IMPORT BLOCKED: project identity mismatch. {w}\n\
424                 Set `io.allow_cross_project_search = true` in your role to allow cross-project imports."
425            ));
426        }
427    }
428
429    let schema_version = bundle.schema_version;
430    let ledger = bundle.ledger;
431
432    let apply_workflow = get_bool(args, "apply_workflow").unwrap_or(true);
433    let apply_session = get_bool(args, "apply_session").unwrap_or(true);
434    let apply_knowledge = get_bool(args, "apply_knowledge").unwrap_or(true);
435
436    if apply_workflow {
437        let mut wf = ctx.workflow.as_ref().unwrap().blocking_write();
438        // Never restore a terminal "done" workflow — it would block all tools
439        if ledger
440            .workflow
441            .as_ref()
442            .is_some_and(|r| r.current == "done")
443        {
444            *wf = None;
445        } else {
446            wf.clone_from(&ledger.workflow);
447        }
448    }
449
450    if apply_session {
451        let session_handle = ctx.session.as_ref().unwrap();
452        let mut session = session_handle.blocking_write();
453        if let Some(t) = ledger.session.task.as_deref() {
454            session.set_task(t, None);
455        }
456        for d in &ledger.session.decisions {
457            session.add_decision(d, None);
458        }
459        for f in &ledger.session.findings {
460            session.add_finding(None, None, f);
461        }
462        session.next_steps.clone_from(&ledger.session.next_steps);
463        let _ = session.save();
464    }
465
466    let (knowledge_imported, contradictions) = if apply_knowledge {
467        import_knowledge_from_ledger(ctx, &ledger)?
468    } else {
469        (0, 0)
470    };
471
472    Ok(crate::tools::ctx_handoff::format_imported(
473        jailed.as_path(),
474        schema_version,
475        knowledge_imported,
476        contradictions,
477        warning.as_deref(),
478    ))
479}
480
481/// Shared knowledge import logic used by both pull and import actions.
482fn import_knowledge_from_ledger(
483    ctx: &ToolContext,
484    ledger: &crate::core::handoff_ledger::HandoffLedgerV1,
485) -> Result<(u32, u32), ErrorData> {
486    let project_root = ctx.project_root.clone();
487    let session_id = {
488        let s = ctx.session.as_ref().unwrap().blocking_read();
489        s.id.clone()
490    };
491
492    let policy = match crate::core::config::Config::load().memory_policy_effective() {
493        Ok(p) => p,
494        Err(e) => {
495            let path = crate::core::config::Config::path().map_or_else(
496                || "~/.lean-ctx/config.toml".to_string(),
497                |p| p.display().to_string(),
498            );
499            return Err(ErrorData::internal_error(
500                format!("Error: invalid memory policy: {e}\nFix: edit {path}"),
501                None,
502            ));
503        }
504    };
505
506    let mut knowledge = crate::core::knowledge::ProjectKnowledge::load_or_create(&project_root);
507    let mut imported = 0u32;
508    let mut contradictions = 0u32;
509    for fact in &ledger.knowledge.facts {
510        let c = knowledge.remember(
511            &fact.category,
512            &fact.key,
513            &fact.value,
514            &session_id,
515            fact.confidence,
516            &policy,
517        );
518        if c.is_some() {
519            contradictions += 1;
520        }
521        imported += 1;
522    }
523    let _ = knowledge.run_memory_lifecycle(&policy);
524    let _ = knowledge.save();
525
526    Ok((imported, contradictions))
527}