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