Skip to main content

lean_ctx_server/tools/registered/
ctx_pack.rs

1// SPDX-License-Identifier: Apache-2.0
2use 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
328/// `action=bundle` (#1885): one budgeted XML document for a chat product.
329/// Returns the XML (or only the report for `emit=plain`); with `file` the XML
330/// is written there and only the report comes back. The secret scan is
331/// always on over MCP.
332fn 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    // The reads of the build run in the request's project scope and source
381    // capture (dispatch binds both). A bundle is a mixed output — tree,
382    // summary, task and knowledge text around the files — so `build` marks
383    // the capture incomplete and this tool declares no source set
384    // (`publishes_every_source` stays `false`). A bundle that could not be
385    // built, or that does not fit its limit, is an error result.
386    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        // A file leaves the delivery path of this result: it stays inside
400        // the project (the export rule) and receives the released text.
401        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
421/// The bundle as the release point lets it leave, for a destination that is
422/// not this call's result (the `file`): the same sequence every tool result
423/// passes — release, then the output redaction. `Err` is the content-free
424/// notice of a refused release; nothing is written then.
425fn 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    /// #475: `file` must not write into a read-only root.
507    #[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    /// V01: `create` with a selection packs exactly the selected facts, each
532    /// with its status; `preview` lists exactly that and writes nothing. A
533    /// fact merged from a package without a redistribution grant stays out
534    /// (V02).
535    #[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}