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