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