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
457fn 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}