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