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