Skip to main content

lean_ctx_server/tools/registered/
ctx_url_read.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::{McpTool, ToolContext, ToolOutput, get_int, get_str};
7use lean_ctx::core::context_admission::origin::{InputKind, InputOrigin};
8use lean_ctx::core::protocol::append_savings;
9use lean_ctx::core::tokens::count_tokens;
10use lean_ctx::core::web::{self, ReadMode, ReadOptions};
11use lean_ctx::tool_defs::tool_def;
12
13/// `ctx_url_read` — fetch a web page, PDF, or YouTube video and return
14/// compressed, citation-backed context (HTML/PDF→text, transcript flattening,
15/// extractive research-compression modes).
16pub struct CtxUrlReadTool;
17
18impl McpTool for CtxUrlReadTool {
19    fn name(&self) -> &'static str {
20        "ctx_url_read"
21    }
22
23    fn tool_def(&self) -> Tool {
24        tool_def(
25            "ctx_url_read",
26            "Fetch URL: pages→Markdown; PDF→text; YouTube→transcript; mode=auto best per type.\n\
27             mode=facts|quotes for research (claims+confidence). query='topic' focuses extraction.\n\
28             GitHub blob/raw URLs auto-resolve to raw file. SSRF-guarded (no private IPs).\n\
29             max_tokens=6000; timeout_secs=20 (max 60).\n",
30            json!({
31                "type": "object",
32                "properties": {
33                    "url": { "type": "string", "description": "http(s) URL (page, PDF, YouTube)" },
34                    "mode": {
35                        "type": "string",
36                        "enum": ["auto", "markdown", "text", "links", "facts", "quotes", "transcript"],
37                        "description": "auto|markdown|text|links|facts|quotes|transcript"
38                    },
39                    "query": { "type": "string", "description": "Focus query (boosts facts/quotes)" },
40                    "max_tokens": { "type": "integer", "description": "Token budget" },
41                    "max_items": { "type": "integer", "description": "Max items for facts/quotes" },
42                    "timeout_secs": { "type": "integer", "description": "Timeout seconds" }
43                },
44                "required": ["url"]
45            }),
46        )
47    }
48
49    fn handle(
50        &self,
51        args: &Map<String, Value>,
52        _ctx: &ToolContext,
53    ) -> Result<ToolOutput, ErrorData> {
54        let url = get_str(args, "url")
55            .ok_or_else(|| ErrorData::invalid_params("url is required", None))?;
56
57        let mode = match get_str(args, "mode") {
58            Some(m) => ReadMode::parse(&m).ok_or_else(|| {
59                ErrorData::invalid_params(
60                    format!("invalid mode '{m}' (use: auto, markdown, text, links, facts, quotes, transcript)"),
61                    None,
62                )
63            })?,
64            None => ReadMode::Auto,
65        };
66
67        let query = get_str(args, "query");
68        let max_tokens = get_int(args, "max_tokens")
69            .map_or(web::DEFAULT_MAX_TOKENS, |n| n.clamp(200, 50_000) as usize);
70        let max_items =
71            get_int(args, "max_items").map_or(web::DEFAULT_MAX_ITEMS, |n| n.clamp(1, 100) as usize);
72        let timeout_secs = get_int(args, "timeout_secs")
73            .map_or(web::fetch::DEFAULT_TIMEOUT_SECS, |n| n.clamp(1, 60) as u64);
74
75        let opts = ReadOptions {
76            url: &url,
77            mode,
78            query: query.as_deref(),
79            max_tokens,
80            max_items,
81            timeout_secs,
82        };
83
84        // Admission runs twice, on purpose: on the extracted source text
85        // BEFORE any mode distils it (`read_url_admitted`), and on the text
86        // that is finally delivered, footer included (`admitted`).
87        let result = tokio::task::block_in_place(|| read_noted(&opts));
88
89        result.map_err(|e| ErrorData::invalid_params(format!("ctx_url_read failed: {e}"), None))
90    }
91}
92
93/// Fetch and admit `opts.url`. The page's origin is noted on the call before
94/// anything is fetched (review 22480 finding 5): a fetch error names the URL
95/// and text the server sent (a content type, a status line), and the release
96/// point fences an error only for a call whose ledger is not empty.
97fn read_noted(opts: &ReadOptions<'_>) -> Result<ToolOutput, String> {
98    let origin = web_origin(opts.url);
99    lean_ctx::core::context_admission::capture::note_origin(
100        &lean_ctx::core::context_admission::origin::SourceClass::External(origin.clone()),
101    );
102    web::read_url_admitted(opts, &|text: &str| admit_extracted_as(text, &origin)).and_then(admitted)
103}
104
105/// Admission of the extracted source text (page Markdown, PDF text,
106/// transcript, rendered link list, title), applied by `core::web` before it
107/// renders, ranks or summarizes anything (G01). A fetched body is a source
108/// that is not a file: project policy on the text, then the gateway.
109#[cfg(test)]
110fn admit_extracted(text: &str) -> Result<String, String> {
111    crate::tools::admitted_sources::admit_remote_text(text, "ctx_url_read")
112        .map(|admission| admission.text)
113}
114
115/// The origin of a fetched page (pz21 §1.1, `web`): its URL without query
116/// and fragment, content-free.
117fn web_origin(url: &str) -> InputOrigin {
118    InputOrigin::new(InputKind::Web, source_reference(url))
119}
120
121/// [`admit_extracted`] for a page of known origin: untrusted, fenced.
122fn admit_extracted_as(text: &str, origin: &InputOrigin) -> Result<String, String> {
123    crate::tools::admitted_sources::admit_remote_text_as(text, "ctx_url_read", origin)
124        .map(|admission| admission.text)
125}
126
127/// What a receipt trace names as the source of a fetched text: scheme, host
128/// and path. Query, fragment and user information can carry a credential no
129/// detector knows, so they are never part of the reference.
130fn source_reference(url: &str) -> String {
131    let end = url.find(['?', '#']).unwrap_or(url.len());
132    let plain = &url[..end];
133    let Some((scheme, rest)) = plain.split_once("://") else {
134        return plain.to_owned();
135    };
136    let authority_end = rest.find('/').unwrap_or(rest.len());
137    let host = rest[..authority_end]
138        .rsplit_once('@')
139        .map_or(&rest[..authority_end], |(_, host)| host);
140    format!("{scheme}://{host}{}", &rest[authority_end..])
141}
142
143/// The last check before a fetched text becomes a tool result: the text this
144/// call delivers, citation footer included, is admitted once more, and only
145/// the admitted text is counted, footed and returned. A text that is withheld
146/// yields the content-free reason as the tool's error; it never becomes an
147/// empty or partial success. A body `core::web` cannot extract as text (an
148/// unsupported content type) is refused there, in every mode.
149fn admitted(read: web::ReadResult) -> Result<ToolOutput, String> {
150    let content = crate::tools::admitted_sources::admit_remote_text_as(
151        &read.content,
152        "ctx_url_read",
153        &web_origin(&read.final_url),
154    )?
155    .text;
156    lean_ctx::core::context_admission::capture::note_source_id(&source_reference(&read.final_url));
157    let sent = count_tokens(&content);
158    let saved = read.original_tokens.saturating_sub(sent);
159    let text = append_savings(&content, read.original_tokens, sent);
160    Ok(ToolOutput {
161        text,
162        original_tokens: read.original_tokens,
163        saved_tokens: saved,
164        mode: Some(read.mode.label().to_string()),
165        path: Some(read.final_url),
166        changed: false,
167        shell_outcome: None,
168        content_blocks: None,
169    })
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    /// Review 22480 finding 5: the origin was noted only after extraction
177    /// succeeded, so a fetch error was released raw.
178    #[test]
179    fn a_failed_fetch_is_noted_as_outside_input() {
180        use lean_ctx::core::context_admission::capture::{ADMISSIONS, AdmissionCapture};
181        let _data = lean_ctx::core::data_dir::isolated_data_dir();
182        let capture = AdmissionCapture::new();
183        let opts = ReadOptions {
184            url: "http://127.0.0.1:9/refused?token=secret",
185            mode: ReadMode::Text,
186            query: None,
187            max_tokens: web::DEFAULT_MAX_TOKENS,
188            max_items: web::DEFAULT_MAX_ITEMS,
189            timeout_secs: 1,
190        };
191        let result = ADMISSIONS.sync_scope(Some(capture.clone()), || read_noted(&opts));
192        assert!(result.is_err(), "a private address is refused");
193        let ledger = capture.origin_ledger();
194        assert!(!ledger.is_empty(), "the failed fetch left no origin");
195        assert!(
196            ledger
197                .refs
198                .iter()
199                .all(|input| !format!("{input:?}").contains("secret")),
200            "{:?}",
201            ledger.refs
202        );
203    }
204
205    const AWS_KEY: &str = concat!("AK", "IAIOSFODNN7EXAMPLE");
206
207    fn fetched(content: &str) -> web::ReadResult {
208        web::ReadResult {
209            content: content.to_string(),
210            mode: ReadMode::Markdown,
211            original_tokens: 400,
212            final_url: "https://docs.example/page".to_string(),
213        }
214    }
215
216    /// G01: the old handler returned the fetched text as it came — a key on
217    /// a page reached the model unless the release point caught it, and a
218    /// page the project policy blocks was delivered. A clean page keeps its
219    /// bytes.
220    #[test]
221    fn a_fetched_body_is_admitted_before_it_is_returned() {
222        let _data = lean_ctx::core::data_dir::isolated_data_dir();
223        {
224            let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
225            let clean = "# Guide\n\nInstall the widget, then run it.\n\n[source](https://docs.example/page)";
226            let Ok(output) = admitted(fetched(clean)) else {
227                panic!("a clean page is delivered");
228            };
229            let sent = count_tokens(clean);
230            assert_eq!(output.text, append_savings(clean, 400, sent));
231            assert_eq!(output.path.as_deref(), Some("https://docs.example/page"));
232
233            let keyed = format!("# Setup\n\nexport AWS_ACCESS_KEY_ID={AWS_KEY}\n");
234            let Ok(output) = admitted(fetched(&keyed)) else {
235                panic!("a page with a maskable value is delivered masked");
236            };
237            assert!(!output.text.contains(AWS_KEY), "{}", output.text);
238            assert!(output.text.contains("# Setup"), "{}", output.text);
239        }
240        let policy = lean_ctx::core::policy::load(
241            "name = \"ctx-url-read-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n\
242             [filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
243        )
244        .unwrap();
245        let _policy = lean_ctx::core::policy::runtime::TestPolicyOverride::set(Some(policy));
246        let Err(reason) = admitted(fetched("CONFIDENTIAL\nroadmap-marker for the launch\n")) else {
247            panic!("a blocked page must not be delivered");
248        };
249        assert!(reason.contains("withheld"), "{reason}");
250        assert!(!reason.contains("roadmap-marker"), "{reason}");
251        // The same decision is taken on the extracted text, before any mode
252        // distils it (the hook `core::web::read_url_admitted` applies).
253        let reason = admit_extracted("CONFIDENTIAL\nroadmap-marker for the launch\n")
254            .expect_err("blocked before distillation");
255        assert!(!reason.contains("roadmap-marker"), "{reason}");
256        assert_eq!(
257            admit_extracted("plain extracted text\n").as_deref(),
258            Ok("plain extracted text\n")
259        );
260    }
261}