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