1use rmcp::ErrorData;
3use rmcp::model::Tool;
4use serde_json::{Map, Value, json};
5
6use crate::server::tool_trait::{
7 McpTool, ToolContext, ToolOutput, get_bool, get_int, get_str, get_str_array, get_usize,
8 tool_execution_error,
9};
10use lean_ctx::tool_defs::tool_def;
11
12pub struct CtxPackTool;
13
14impl McpTool for CtxPackTool {
15 fn name(&self) -> &'static str {
16 "ctx_pack"
17 }
18
19 fn tool_def(&self) -> Tool {
20 tool_def(
21 "ctx_pack",
22 "WORKFLOW: create -> export -> import -> install for sharing context state.\n\
23 ANTIPATTERN: NOT for ephemeral session save (use ctx_session).\n\
24 Context Package Manager — create, install, manage portable context packages\n\
25 with knowledge, graph, session patterns, and gotchas.\n\
26 Actions: pr, create, list, info, remove, install, export, import, auto_load, summary, bundle.\n\
27 create: select layers/categories/keys/facts; preview:true lists what would be packed, writes nothing.\n\
28 bundle: one budgeted XML document (task-ranked files, signatures, tree) that fits a chat input box.\n\
29 Saves tokens: pre-built context state (avoids re-building).",
30 json!({
31 "type": "object",
32 "properties": {
33 "action": {
34 "type": "string",
35 "enum": ["pr", "create", "list", "info", "remove", "install", "export", "import", "auto_load", "summary", "bundle"],
36 "description": "Pack action to perform"
37 },
38 "path": {
39 "type": "string",
40 "description": "Directory or file to bundle (bundle; default: project root)"
41 },
42 "limit": {
43 "type": "string",
44 "description": "Hard size cap, e.g. 128k, 2M, 32000 (bundle; default 128k)"
45 },
46 "unit": {
47 "type": "string",
48 "enum": ["chars", "tokens"],
49 "description": "What limit counts (bundle; default chars)"
50 },
51 "intent": {
52 "type": "string",
53 "description": "Task the bundle is for; ranks files (bundle; default: session task)"
54 },
55 "emit": {
56 "type": "string",
57 "enum": ["xml", "plain", "both"],
58 "description": "xml bundle, plain allocation report, or both (bundle)"
59 },
60 "include": {
61 "type": "array",
62 "items": { "type": "string" },
63 "description": "Globs to include (bundle)"
64 },
65 "ignore": {
66 "type": "array",
67 "items": { "type": "string" },
68 "description": "Globs to exclude (bundle)"
69 },
70 "with_knowledge": {
71 "type": "array",
72 "items": { "type": "string" },
73 "description": "Knowledge categories to append, e.g. decision, architecture, or all (bundle)"
74 },
75 "knowledge_limit": {
76 "type": "integer",
77 "description": "Max knowledge facts (bundle; default 10)"
78 },
79 "with_auto": {
80 "type": "boolean",
81 "description": "Also include machine-derived auto:* facts (bundle)"
82 },
83 "project_root": {
84 "type": "string",
85 "description": "Project root directory"
86 },
87 "name": {
88 "type": "string",
89 "description": "Package name"
90 },
91 "version": {
92 "type": "string",
93 "description": "Package version (semver)"
94 },
95 "description": {
96 "type": "string",
97 "description": "Package description (for create)"
98 },
99 "author": {
100 "type": "string",
101 "description": "Package author (for create)"
102 },
103 "tags": {
104 "type": "array",
105 "items": { "type": "string" },
106 "description": "Tags for categorization (for create)"
107 },
108 "layers": {
109 "type": "array",
110 "items": { "type": "string" },
111 "description": "Layers to include: knowledge|graph|session|patterns|gotchas"
112 },
113 "categories": {
114 "type": "array",
115 "items": { "type": "string" },
116 "description": "Only facts of these categories (create)"
117 },
118 "keys": {
119 "type": "array",
120 "items": { "type": "string" },
121 "description": "Only facts with these keys (create)"
122 },
123 "facts": {
124 "type": "array",
125 "items": { "type": "string" },
126 "description": "Explicit facts as category/key (create)"
127 },
128 "preview": {
129 "type": "boolean",
130 "description": "Show what create would pack (items, status, sources, validity); writes nothing"
131 },
132 "redistributable": {
133 "type": "boolean",
134 "description": "Grant recipients the right to pass the package on (create)"
135 },
136 "level": {
137 "type": "integer",
138 "description": "Detail level 1-3 (higher = more detail)"
139 },
140 "scope": {
141 "type": "string",
142 "description": "Package scope (e.g. @org/name)"
143 },
144 "base": {
145 "type": "string",
146 "description": "Git base ref for PR diff"
147 },
148 "format": {
149 "type": "string",
150 "enum": ["markdown", "json"],
151 "description": "Output format: markdown|json"
152 },
153 "depth": {
154 "type": "integer",
155 "description": "Impact depth for pr action (default: 3)"
156 },
157 "diff": {
158 "type": "string",
159 "description": "Git diff --name-status text input"
160 },
161 "file": {
162 "type": "string",
163 "description": "File path for import/export; bundle output file"
164 },
165 "apply": {
166 "type": "boolean",
167 "description": "Apply after import (default: false)"
168 },
169 "enable": {
170 "type": "boolean",
171 "description": "Enable auto-load (default: true)"
172 }
173 },
174 "required": ["action"],
175 "allOf": [
176 {
177 "if": { "properties": { "action": { "const": "create" } }, "required": ["action"] },
178 "then": { "required": ["action", "name"] }
179 },
180 {
181 "if": { "properties": { "action": { "const": "info" } }, "required": ["action"] },
182 "then": { "required": ["action", "name"] }
183 },
184 {
185 "if": { "properties": { "action": { "const": "remove" } }, "required": ["action"] },
186 "then": { "required": ["action", "name"] }
187 },
188 {
189 "if": { "properties": { "action": { "const": "install" } }, "required": ["action"] },
190 "then": { "required": ["action", "name"] }
191 },
192 {
193 "if": { "properties": { "action": { "const": "export" } }, "required": ["action"] },
194 "then": { "required": ["action", "name"] }
195 },
196 {
197 "if": { "properties": { "action": { "const": "import" } }, "required": ["action"] },
198 "then": { "required": ["action", "file"] }
199 }
200 ]
201 }),
202 )
203 }
204
205 fn handle(
206 &self,
207 args: &Map<String, Value>,
208 ctx: &ToolContext,
209 ) -> Result<ToolOutput, ErrorData> {
210 let action = get_str(args, "action")
211 .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
212
213 let project_root = if let Some(p) = ctx
214 .resolved_path("project_root")
215 .or(ctx.resolved_path("root"))
216 {
217 p.to_string()
218 } else if let Some(err) = ctx.path_error("project_root").or(ctx.path_error("root")) {
219 return Err(ErrorData::invalid_params(
220 format!("project_root: {err}"),
221 None,
222 ));
223 } else {
224 ctx.project_root.clone()
225 };
226
227 let result = match action.as_str() {
228 "pr" => {
229 let base = get_str(args, "base");
230 let format = get_str(args, "format");
231 let depth = get_usize(args, "depth").map(|d| d.min(64));
232 let diff = get_str(args, "diff");
233 crate::tools::ctx_pack::handle(
234 "pr",
235 &project_root,
236 base.as_deref(),
237 format.as_deref(),
238 depth,
239 diff.as_deref(),
240 )
241 }
242 "create" => {
243 let name = get_str(args, "name")
244 .ok_or_else(|| ErrorData::invalid_params("name is required for create", None))?;
245 use lean_ctx::core::context_package::selection::{PackRequest, PackSelection};
246 let request = PackRequest {
247 name,
248 version: get_str(args, "version").unwrap_or_else(|| "1.0.0".into()),
249 description: get_str(args, "description").unwrap_or_default(),
250 author: get_str(args, "author"),
251 tags: get_str_array(args, "tags").unwrap_or_default(),
252 level: get_int(args, "level")
253 .and_then(|l| u32::try_from(l).ok())
254 .unwrap_or(1)
255 .clamp(1, 3),
256 scope: get_str(args, "scope"),
257 private: false,
258 redistributable: get_bool(args, "redistributable").unwrap_or(false),
259 selection: PackSelection {
260 layers: get_str_array(args, "layers").unwrap_or_default(),
261 categories: get_str_array(args, "categories").unwrap_or_default(),
262 keys: get_str_array(args, "keys").unwrap_or_default(),
263 facts: get_str_array(args, "facts").unwrap_or_default(),
264 },
265 };
266 let preview = get_bool(args, "preview").unwrap_or(false);
267 crate::tools::ctx_pack::handle_create(&project_root, &request, preview)
268 }
269 "list" => crate::tools::ctx_pack::handle_list(),
270 "info" => {
271 let name = get_str(args, "name")
272 .ok_or_else(|| ErrorData::invalid_params("name is required for info", None))?;
273 let version = get_str(args, "version");
274 crate::tools::ctx_pack::handle_info(&name, version.as_deref())
275 }
276 "remove" => {
277 let name = get_str(args, "name")
278 .ok_or_else(|| ErrorData::invalid_params("name is required for remove", None))?;
279 let version = get_str(args, "version");
280 crate::tools::ctx_pack::handle_remove(&name, version.as_deref())
281 }
282 "install" => {
283 let name = get_str(args, "name").ok_or_else(|| {
284 ErrorData::invalid_params("name is required for install", None)
285 })?;
286 let version = get_str(args, "version");
287 crate::tools::ctx_pack::handle_install(&name, version.as_deref(), &project_root)
288 }
289 "export" => {
290 let name = get_str(args, "name").ok_or_else(|| {
291 ErrorData::invalid_params("name is required for export", None)
292 })?;
293 let version = get_str(args, "version");
294 let file = get_str(args, "file")
295 .map(|raw| lean_ctx::core::workspace_authority::confine(&raw, &project_root, true))
296 .transpose()
297 .map_err(|error| ErrorData::invalid_params(error, None))?;
298 crate::tools::ctx_pack::handle_export(&name, version.as_deref(), file.as_deref())
299 }
300 "import" => {
301 let file = get_str(args, "file")
302 .ok_or_else(|| ErrorData::invalid_params("file is required for import", None))?;
303 let file = lean_ctx::core::workspace_authority::confine(&file, &project_root, false)
304 .map_err(|error| ErrorData::invalid_params(error, None))?;
305 let apply = get_bool(args, "apply").unwrap_or(false);
306 crate::tools::ctx_pack::handle_import(&file, apply, &project_root)
307 }
308 "auto_load" => {
309 let name = get_str(args, "name");
310 let version = get_str(args, "version");
311 let enable = get_bool(args, "enable").unwrap_or(true);
312 crate::tools::ctx_pack::handle_auto_load(
313 &project_root,
314 name.as_deref(),
315 version.as_deref(),
316 enable,
317 )
318 }
319 "summary" => crate::tools::ctx_pack::handle_summary(&project_root),
320 "bundle" => handle_bundle(args, ctx, &project_root)?,
321 _ => "Unknown action. Use: pr, create, list, info, remove, install, export, import, auto_load, summary, bundle".to_string(),
322 };
323
324 Ok(ToolOutput::simple(result))
325 }
326}
327
328fn handle_bundle(
333 args: &Map<String, Value>,
334 ctx: &ToolContext,
335 project_root: &str,
336) -> Result<String, ErrorData> {
337 use lean_ctx::core::context_bundle::{self, BundleOptions, Unit, parse_limit};
338 let invalid = |msg: String| ErrorData::invalid_params(msg, None);
339
340 let mut opts = BundleOptions::new(project_root.into());
341 if let Some(path) = ctx.resolved_path("path") {
342 opts.scope = Some(path.into());
343 } else if let Some(err) = ctx.path_error("path") {
344 return Err(invalid(format!("path: {err}")));
345 }
346 match args.get("limit") {
347 Some(Value::String(raw)) => opts.limit = parse_limit(raw).map_err(invalid)?,
348 Some(Value::Number(n)) => {
349 opts.limit = n
350 .as_u64()
351 .and_then(|n| usize::try_from(n).ok())
352 .filter(|n| *n > 0)
353 .ok_or_else(|| invalid("limit must be a positive integer".into()))?;
354 }
355 Some(_) => {
356 return Err(invalid(
357 "limit must be a number or a string like 128k".into(),
358 ));
359 }
360 None => {}
361 }
362 if let Some(unit) = get_str(args, "unit") {
363 opts.unit = Unit::parse(&unit).map_err(invalid)?;
364 }
365 opts.intent = get_str(args, "intent");
366 opts.include = get_str_array(args, "include").unwrap_or_default();
367 opts.exclude = get_str_array(args, "ignore").unwrap_or_default();
368 opts.knowledge = get_str_array(args, "with_knowledge").filter(|c| !c.is_empty());
369 if let Some(n) = get_usize(args, "knowledge_limit") {
370 opts.knowledge_limit = n;
371 }
372 opts.knowledge_auto = get_bool(args, "with_auto").unwrap_or(false);
373
374 let emit = get_str(args, "emit").unwrap_or_else(|| "xml".into());
375 if !matches!(emit.as_str(), "xml" | "plain" | "both") {
376 return Err(invalid(format!(
377 "emit must be xml, plain or both (got '{emit}')"
378 )));
379 }
380 let bundle = context_bundle::build(&opts).map_err(tool_execution_error)?;
387 let report = bundle.report();
388 if !bundle.fits() {
389 return Err(tool_execution_error(format!(
390 "{report}bundle is {} {} but the limit is {} — raise limit or narrow path",
391 bundle.size, bundle.unit, bundle.limit
392 )));
393 }
394
395 if let Some(file) = ctx.resolved_path("file") {
396 if emit == "plain" {
397 return Err(invalid("emit=plain writes no bundle; drop file".into()));
398 }
399 let output = context_bundle::export::destination(
402 "ctx_pack",
403 std::path::Path::new(project_root),
404 std::path::Path::new(file),
405 )
406 .map_err(invalid)?;
407 let released = released_bundle(bundle.xml).map_err(tool_execution_error)?;
408 std::fs::write(&output, released)
409 .map_err(|e| tool_execution_error(format!("cannot write {file}: {e}")))?;
410 return Ok(format!("{report}written: {file}"));
411 } else if let Some(err) = ctx.path_error("file") {
412 return Err(invalid(format!("file: {err}")));
413 }
414 Ok(match emit.as_str() {
415 "plain" => report,
416 "both" => format!("{report}\n{}", bundle.xml),
417 _ => bundle.xml,
418 })
419}
420
421fn released_bundle(xml: String) -> Result<String, String> {
426 use rmcp::model::{CallToolResult, ContentBlock};
427 let released = crate::server::policy_guard::release_result(
428 "ctx_pack",
429 CallToolResult::success(vec![ContentBlock::text(xml)]),
430 );
431 let text = released
432 .content
433 .iter()
434 .filter_map(|block| block.as_text().map(|text| text.text.clone()))
435 .collect::<Vec<_>>()
436 .join("\n");
437 if released.is_error == Some(true) {
438 return Err(text);
439 }
440 Ok(crate::server::policy_guard::redact_model_text(&text))
441}
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446
447 fn project() -> tempfile::TempDir {
448 let dir = tempfile::tempdir().unwrap();
449 std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
450 dir
451 }
452
453 fn ctx_for(root: &std::path::Path, file: Option<&std::path::Path>) -> ToolContext {
454 let mut ctx = ToolContext {
455 project_root: root.to_string_lossy().into_owned(),
456 ..Default::default()
457 };
458 if let Some(file) = file {
459 ctx.resolved_paths
460 .insert("file".into(), file.to_string_lossy().into_owned());
461 }
462 ctx
463 }
464
465 fn bundle(ctx: &ToolContext, extra: Value) -> Result<String, ErrorData> {
466 let mut args = json!({ "action": "bundle", "limit": "8k" });
467 if let (Some(args), Value::Object(extra)) = (args.as_object_mut(), extra) {
468 args.extend(extra);
469 }
470 CtxPackTool
471 .handle(args.as_object().unwrap(), ctx)
472 .map(|out| out.text)
473 }
474
475 #[test]
476 fn bundle_returns_xml_within_the_limit() {
477 let _iso = lean_ctx::core::data_dir::isolated_data_dir();
478 let dir = project();
479 let xml = bundle(&ctx_for(dir.path(), None), json!({})).unwrap();
480 assert!(xml.starts_with("<bundle "), "{xml}");
481 assert!(xml.contains("fn main()"), "{xml}");
482 assert!(xml.chars().count() <= 8000);
483 }
484
485 #[test]
486 fn bundle_rejects_bad_emit_and_limit() {
487 let _iso = lean_ctx::core::data_dir::isolated_data_dir();
488 let dir = project();
489 let ctx = ctx_for(dir.path(), None);
490 assert!(bundle(&ctx, json!({ "emit": "html" })).is_err());
491 assert!(bundle(&ctx, json!({ "limit": 0 })).is_err());
492 assert!(bundle(&ctx, json!({ "limit": true })).is_err());
493 }
494
495 #[test]
496 fn bundle_writes_the_file_and_returns_the_report() {
497 let _iso = lean_ctx::core::data_dir::isolated_data_dir();
498 let dir = project();
499 let out = dir.path().join("bundle.xml");
500 let report = bundle(&ctx_for(dir.path(), Some(&out)), json!({})).unwrap();
501 assert!(report.contains("written:"), "{report}");
502 let xml = std::fs::read_to_string(&out).unwrap();
503 assert!(xml.starts_with("<bundle "), "{xml}");
504 }
505
506 #[test]
508 fn bundle_refuses_to_write_into_a_read_only_root() {
509 let _iso = lean_ctx::core::data_dir::isolated_data_dir();
510 let dir = project();
511 let ro = dir.path().join("refrepo");
512 std::fs::create_dir_all(&ro).unwrap();
513 let out = ro.join("bundle.xml");
514
515 let ro_canon = lean_ctx::core::pathjail::canonicalize_or_self(&ro);
516 lean_ctx::test_env::set_var(
517 "LEAN_CTX_READ_ONLY_ROOTS",
518 ro_canon.to_string_lossy().as_ref(),
519 );
520 let result = bundle(&ctx_for(dir.path(), Some(&out)), json!({}));
521 lean_ctx::test_env::remove_var("LEAN_CTX_READ_ONLY_ROOTS");
522
523 let err = result.expect_err("write into a read-only root must be refused");
524 assert!(err.message.contains("read-only"), "{err:?}");
525 assert!(
526 !out.exists(),
527 "no bundle may be written into a read-only root"
528 );
529 }
530
531 #[test]
536 fn create_packs_the_selection_and_preview_shows_it_without_writing() {
537 use lean_ctx::core::context_package::LocalRegistry;
538 use lean_ctx::core::knowledge::{FactStatus, ProjectKnowledge};
539 let _iso = lean_ctx::core::data_dir::isolated_data_dir();
540 let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
541 let dir = project();
542 let root = dir.path().to_string_lossy().into_owned();
543 ProjectKnowledge::mutate_locked(&root, |k| {
544 let policy = lean_ctx::core::memory_policy::MemoryPolicy::default();
545 for (category, key, value) in [
546 ("architecture", "db", "orders live in postgres 16"),
547 ("architecture", "cache", "sessions are cached in redis"),
548 ("architecture", "queue", "jobs run on a vendor queue"),
549 ("decision", "style", "the parser is hand-written"),
550 ] {
551 k.remember(category, key, value, "fixture", 0.9, &policy);
552 let fact = k.facts.last_mut().unwrap();
553 match key {
554 "db" => fact.status = Some(FactStatus::Verified),
555 "queue" => fact.imported_from = Some("vendor-notes@1.0.0".into()),
556 _ => {}
557 }
558 }
559 })
560 .unwrap();
561 let ctx = ctx_for(dir.path(), None);
562 let create = |preview: bool| {
563 let args = json!({
564 "action": "create", "name": "selected", "layers": ["knowledge"],
565 "categories": ["architecture"], "preview": preview,
566 });
567 CtxPackTool
568 .handle(args.as_object().unwrap(), &ctx)
569 .unwrap()
570 .text
571 };
572
573 let preview = create(true);
574 assert!(
575 LocalRegistry::open().unwrap().list().unwrap().is_empty(),
576 "a preview writes nothing: {preview}"
577 );
578 assert!(
579 preview.contains("Knowledge facts: 2 (1 verified, 1 assumption)"),
580 "{preview}"
581 );
582 assert!(preview.contains("[verified] architecture/db: orders live in postgres 16"));
583 assert!(
584 preview.contains("[assumption] architecture/cache"),
585 "{preview}"
586 );
587 assert!(preview.contains("Not selected: 1 fact(s)"), "{preview}");
588 assert!(
589 preview.contains("Withheld: architecture/queue — merged from vendor-notes@1.0.0"),
590 "{preview}"
591 );
592 assert!(!preview.contains("decision/style"), "{preview}");
593
594 let created = create(false);
595 let (_, content) = LocalRegistry::open()
596 .unwrap()
597 .load_package("selected", "1.0.0")
598 .unwrap_or_else(|e| panic!("{e}: {created}"));
599 let packed: Vec<_> = content
600 .knowledge
601 .unwrap()
602 .facts
603 .into_iter()
604 .map(|f| (format!("{}/{}", f.category, f.key), f.status))
605 .collect();
606 assert_eq!(
607 packed,
608 [
609 ("architecture/db".into(), Some(FactStatus::Verified)),
610 ("architecture/cache".into(), Some(FactStatus::Assumption)),
611 ]
612 );
613 for (fact, status) in &packed {
614 let line = format!("[{}] {fact}:", status.unwrap().as_str());
615 assert!(preview.contains(&line), "preview lacks {line}: {preview}");
616 }
617 }
618}