lean_ctx_server/tools/registered/
ctx_url_read.rs1use 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
13pub 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 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
93fn 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#[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
115fn web_origin(url: &str) -> InputOrigin {
118 InputOrigin::new(InputKind::Web, source_reference(url))
119}
120
121fn 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
127fn 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
143fn 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 #[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 #[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 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}