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