Skip to main content

lean_ctx_server/tools/registered/
ctx_read_dispatch.rs

1// SPDX-License-Identifier: Apache-2.0
2//! MCP schema and dispatch for the single ctx_read implementation.
3
4use super::{
5    CtxReadTool, ErrorData, Map, McpTool, Tool, ToolContext, ToolOutput, Value, engine, get_str,
6    record_attribution_result, require_resolved_path,
7};
8
9impl McpTool for CtxReadTool {
10    fn name(&self) -> &'static str {
11        "ctx_read"
12    }
13
14    fn produces_machine_readable(&self, args: Option<&Map<String, Value>>) -> bool {
15        // The explicit Engine view is a byte-exact payload, even when it is text.
16        // Security and final turn-budget enforcement still run in the pipeline.
17        crate::server::native_receipts::requested("ctx_read", args)
18    }
19
20    fn tool_def(&self) -> Tool {
21        super::schema::ctx_read_tool_def()
22    }
23
24    fn handle(
25        &self,
26        args: &Map<String, Value>,
27        ctx: &ToolContext,
28    ) -> Result<ToolOutput, ErrorData> {
29        let engine_interface_v1 = engine::interface_v1_requested(args)?;
30        engine::validate_v1_request_shape(args, engine_interface_v1)?;
31        // #509: ctx_read absorbs multi-file batch reads (supersedes ctx_multi_read).
32        // A non-empty `paths` array routes to the one shared batch implementation.
33        if args
34            .get("paths")
35            .and_then(|v| v.as_array())
36            .is_some_and(|a| !a.is_empty())
37        {
38            let result = super::super::ctx_read_batch::batch_read(args, ctx);
39            if let Ok(output) = &result {
40                record_attribution_result(ctx, "ctx_read batch".to_string(), output);
41            }
42            return result;
43        }
44
45        let path = if let Some(repo) = get_str(args, "repo") {
46            let root = lean_ctx::core::multi_repo::resolve_repo_root(&repo).ok_or_else(|| {
47                let known = lean_ctx::core::multi_repo::known_aliases().join(", ");
48                let known = if known.is_empty() {
49                    "none registered — use ctx_multi_repo add_root".to_string()
50                } else {
51                    known
52                };
53                ErrorData::invalid_params(
54                    format!("unknown repo alias: {repo} (known: {known})"),
55                    None,
56                )
57            })?;
58            let rel = get_str(args, "path").unwrap_or_else(|| ".".to_string());
59            lean_ctx::core::path_resolve::resolve_tool_path(Some(&root), None, &rel)
60                .map_err(|e| ErrorData::invalid_params(e, None))?
61        } else {
62            require_resolved_path(ctx, args, "path")?
63        };
64
65        // "File not found" is the most frequent read failure, and often the
66        // path is one mechanical slip from a real file (other case, wrong
67        // prefix). When exactly one file fits and the jail admits it, read it
68        // and say so instead of failing. Byte-exact reads (the engine view,
69        // `raw`) never substitute: their output must be the file and nothing else.
70        let byte_exact = engine_interface_v1
71            || get_str(args, "mode").as_deref() == Some("raw")
72            || args.get("raw").and_then(Value::as_bool) == Some(true);
73        let (path, substituted) = match (!byte_exact)
74            .then(|| {
75                lean_ctx::core::path_suggest::unambiguous_match_in(
76                    std::path::Path::new(&path),
77                    std::path::Path::new(&ctx.project_root),
78                )
79            })
80            .flatten()
81            .and_then(|found| ctx.resolve_path_sync(&found.to_string_lossy()).ok())
82        {
83            Some(found) => (found, Some(path)),
84            None => (path, None),
85        };
86
87        let alias_args = super::window::apply_lines_alias(args);
88        let args = alias_args.as_ref().unwrap_or(args);
89
90        // `symbol=` narrows the read to one definition's lines; the content then
91        // flows through the ordinary `lines:`/`anchored:` path.
92        let symbol_args;
93        let mut symbol_note = None;
94        let args = match get_str(args, "symbol").filter(|s| !s.trim().is_empty()) {
95            None => args,
96            Some(symbol) => {
97                if byte_exact {
98                    return Err(ErrorData::invalid_params(
99                        "symbol= cannot combine with raw or engine_interface",
100                        None,
101                    ));
102                }
103                let (rewritten, note) = symbol_request(args, &path, &symbol)?;
104                symbol_args = rewritten;
105                symbol_note = note;
106                &symbol_args
107            }
108        };
109
110        let result = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
111            self.handle_inner(args, ctx, &path, engine_interface_v1)
112        })) {
113            Ok(result) => result,
114            Err(_) => Err(ErrorData::internal_error(
115                format!(
116                    "ctx_read panicked while processing '{path}'. This is a bug — please report it."
117                ),
118                None,
119            )),
120        };
121        // A trailing service line: the header stays the first line, as every
122        // reader of ctx_read output (and the Claude Code plugin) expects.
123        let result = result.map(|mut output| {
124            if let Some(requested) = &substituted {
125                output.text = format!(
126                    "{}\n[lean-ctx: {requested} does not exist — read {path} instead]",
127                    output.text.trim_end_matches('\n')
128                );
129            }
130            if let Some(note) = &symbol_note {
131                output.text = format!("{}\n{note}", output.text.trim_end_matches('\n'));
132            }
133            output
134        });
135        if let Ok(output) = &result {
136            record_attribution_result(ctx, format!("ctx_read {path}"), output);
137        }
138        result
139    }
140
141    /// Single reads and batches share one implementation per file,
142    /// `handle_inner_prepared`, and that is where the lineage is settled:
143    ///
144    /// - the requested file is published on every path — fresh reads through
145    ///   `tools::ctx_read::read_file_*` (handler thread or
146    ///   `TaskSpine::spawn_thread` workers), and cache hits, `[unchanged]`
147    ///   stubs and deltas through `publish_requested_source`, bound to what
148    ///   is on disk now;
149    /// - whenever the reply carries content from anywhere else — graph
150    ///   neighbours, cross-source hints, rule files, proactively expanded
151    ///   content, the kernel supplement of a batch — the capture is marked
152    ///   incomplete there, so that reply's lineage is unknown;
153    /// - a source that cannot be bound (another repository via `repo`, an
154    ///   image, a path outside the root) marks it incomplete as well.
155    ///
156    /// So a captured set that is still complete at the end IS the whole set,
157    /// for every mode (the modes differ in rendering, not in what they read).
158    fn publishes_every_source(&self, _args: Option<&Map<String, Value>>) -> bool {
159        true
160    }
161}
162
163/// Rewrite a `symbol=` request into the window read that serves it. Under an
164/// active context policy the lookup is refused: resolving names before
165/// admission would answer "does this symbol exist" for a withheld file.
166fn symbol_request(
167    args: &Map<String, Value>,
168    path: &str,
169    symbol: &str,
170) -> Result<(Map<String, Value>, Option<String>), ErrorData> {
171    if lean_ctx::core::policy::runtime::is_active() {
172        return Err(ErrorData::invalid_params(
173            "symbol= is unavailable under an active context policy; use mode=\"lines:N-M\"",
174            None,
175        ));
176    }
177    let content = std::fs::read_to_string(path)
178        .map_err(|e| ErrorData::invalid_params(format!("cannot read {path}: {e}"), None))?;
179    let anchored = get_str(args, "mode").is_some_and(|m| m.starts_with("anchored"));
180    let (mode, note) = match super::symbol::resolve(&content, path, symbol, anchored)
181        .map_err(|e| ErrorData::invalid_params(e, None))?
182    {
183        super::symbol::SymbolRead::Window { mode, note } => (mode, note),
184        // A miss serves the lines that mention the name (or the one-line
185        // reference view when none does) through the ordinary read path, so
186        // admission, redaction and lineage stay the same as for any window.
187        super::symbol::SymbolRead::NotFound { note, hits } if hits.is_empty() => {
188            ("reference".to_string(), Some(note))
189        }
190        super::symbol::SymbolRead::NotFound { note, hits } => {
191            let lines: Vec<String> = hits.iter().map(usize::to_string).collect();
192            (format!("lines:{}", lines.join(",")), Some(note))
193        }
194    };
195    let mut rewritten = args.clone();
196    for key in ["symbol", "start_line", "offset", "limit"] {
197        rewritten.remove(key);
198    }
199    rewritten.insert("mode".into(), Value::String(mode));
200    Ok((rewritten, note))
201}