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