1#![warn(missing_docs)]
8
9use std::time::Duration;
10
11use rmcp::handler::server::wrapper::Parameters;
12use rmcp::tool_router;
13use rmcp::transport::stdio;
14use rmcp::{ServiceExt, tool};
15use schemars::JsonSchema;
16
17use phrona::models::{Category, TimeRange};
18use phrona::{PhronaConfig, ResultItem, SearchClient, SearchOptions};
19
20#[derive(Debug, serde::Deserialize, JsonSchema)]
21struct SearchParams {
22 #[schemars(description = "Search query")]
23 query: String,
24 #[schemars(
25 description = "Comma-separated engine names (default: all available for the category). See list_engines."
26 )]
27 #[serde(default)]
28 engines: Option<String>,
29 #[schemars(description = "Maximum number of results (default 20)")]
30 #[serde(default)]
31 max_results: Option<usize>,
32 #[schemars(description = "Region code, e.g. us-en (default from client)")]
33 #[serde(default)]
34 region: Option<String>,
35 #[schemars(description = "Language code, e.g. en")]
36 #[serde(default)]
37 language: Option<String>,
38 #[schemars(description = "Time range: day, week, month or year")]
39 #[serde(default)]
40 time_range: Option<String>,
41 #[schemars(description = "SafeSearch level: off, moderate or strict (default moderate)")]
42 #[serde(default)]
43 safesearch: Option<String>,
44 #[schemars(description = "Engine-specific filter string, e.g. site:example.com")]
45 #[serde(default)]
46 filters: Option<String>,
47 #[schemars(description = "Result page (default 1)")]
48 #[serde(default)]
49 page: Option<u32>,
50}
51
52#[derive(Debug, serde::Deserialize, JsonSchema)]
53struct FetchParams {
54 #[schemars(description = "URL to fetch and extract readable content from")]
55 url: String,
56 #[schemars(description = "Maximum characters of extracted text (default 8000)")]
57 #[serde(default)]
58 max_chars: Option<usize>,
59 #[schemars(
60 description = "Query used to bias the excerpt toward the relevant section (optional)"
61 )]
62 #[serde(default)]
63 query: Option<String>,
64}
65
66#[derive(Debug, serde::Deserialize, JsonSchema)]
67struct SuggestParams {
68 #[schemars(description = "Partial query to complete")]
69 query: String,
70 #[schemars(
71 description = "Source: duckduckgo, google, bing, brave, startpage, qwant or wikipedia (default: all)"
72 )]
73 #[serde(default)]
74 source: Option<String>,
75 #[schemars(description = "Region code, e.g. us-en (default us-en)")]
76 #[serde(default)]
77 region: Option<String>,
78}
79
80#[derive(Debug, serde::Deserialize, JsonSchema)]
81struct EnginesParams {
82 #[schemars(
83 description = "Category: web, images, news, videos, books, code, papers or archives (default: all)"
84 )]
85 #[serde(default)]
86 category: Option<String>,
87}
88
89#[derive(Clone)]
90struct PhronaMcp {
91 client: std::sync::Arc<SearchClient>,
92 max_results_limit: usize,
93}
94
95impl PhronaMcp {
96 fn with_config(cfg: &PhronaConfig) -> Self {
99 Self {
100 client: std::sync::Arc::new(cfg.search_client().expect("build search client")),
101 max_results_limit: cfg.max_results_limit(),
102 }
103 }
104
105 fn build_opts(
108 p: &SearchParams,
109 category: Category,
110 max_results_limit: usize,
111 ) -> Result<SearchOptions, String> {
112 let mut opts = SearchOptions::new(p.query.clone());
113 opts.category = category;
114 if let Some(es) = &p.engines {
115 opts.engines = es
116 .split(',')
117 .map(str::trim)
118 .filter(|s| !s.is_empty())
119 .map(str::to_string)
120 .collect();
121 }
122 if let Some(m) = p.max_results {
123 opts.max_results = m.clamp(1, max_results_limit.max(1));
125 }
126 opts.region = p.region.clone();
127 opts.language = p.language.clone();
128 if let Some(t) = &p.time_range {
129 opts.time_range = Some(
130 t.parse::<TimeRange>()
131 .map_err(|_| "invalid time_range, expected day|week|month|year".to_string())?,
132 );
133 }
134 if let Some(s) = &p.safesearch {
135 opts.safesearch = s
136 .parse::<phrona::SafeSearch>()
137 .map_err(|_| "invalid safesearch, expected off|moderate|strict".to_string())?;
138 }
139 opts.filters = p.filters.clone();
140 if let Some(page) = p.page {
141 opts.page = page.max(1);
142 }
143 Ok(opts)
144 }
145
146 async fn run_search(&self, p: &SearchParams, category: Category) -> String {
147 let opts = match Self::build_opts(p, category, self.max_results_limit) {
148 Ok(opts) => opts,
149 Err(msg) => {
150 return envelope(serde_json::json!({
151 "query": p.query,
152 "total": 0,
153 "results": [],
154 "error": msg,
155 }));
156 }
157 };
158 match self.client.search(opts).await {
159 Ok(resp) => envelope(serde_json::to_value(&resp).unwrap_or_else(
160 |_| serde_json::json!({"query": p.query, "total": 0, "results": []}),
161 )),
162 Err(e) => envelope(serde_json::json!({
163 "query": p.query,
164 "total": 0,
165 "results": [],
166 "error": e.to_string(),
167 })),
168 }
169 }
170}
171
172fn envelope(v: serde_json::Value) -> String {
178 serde_json::to_string(&v)
179 .unwrap_or_else(|_| r#"{"error":"internal serialization failure"}"#.to_string())
180}
181
182#[tool_router(server_handler)]
183impl PhronaMcp {
184 #[tool(
185 description = "Search the web across multiple metasearch engines. Returns ranked results with title, url, description and the engines that found each one."
186 )]
187 async fn web_search(&self, Parameters(p): Parameters<SearchParams>) -> String {
188 self.run_search(&p, Category::Web).await
189 }
190
191 #[tool(
192 description = "Search images across multiple engines. Returns direct image urls, thumbnails and dimensions."
193 )]
194 async fn image_search(&self, Parameters(p): Parameters<SearchParams>) -> String {
195 self.run_search(&p, Category::Images).await
196 }
197
198 #[tool(
199 description = "Search news across multiple engines. Returns articles with published date and source."
200 )]
201 async fn news_search(&self, Parameters(p): Parameters<SearchParams>) -> String {
202 self.run_search(&p, Category::News).await
203 }
204
205 #[tool(
206 description = "Search videos across multiple engines. Returns video url, duration, views and uploader."
207 )]
208 async fn video_search(&self, Parameters(p): Parameters<SearchParams>) -> String {
209 self.run_search(&p, Category::Videos).await
210 }
211
212 #[tool(description = "Search books and academic material.")]
213 async fn book_search(&self, Parameters(p): Parameters<SearchParams>) -> String {
214 self.run_search(&p, Category::Books).await
215 }
216
217 #[tool(
218 description = "Search GitHub repositories (code). Returns repo name, url, stars, language and description."
219 )]
220 async fn code_search(&self, Parameters(p): Parameters<SearchParams>) -> String {
221 self.run_search(&p, Category::Code).await
222 }
223
224 #[tool(
225 description = "Search arXiv academic papers. Returns title, authors, abstract and published date."
226 )]
227 async fn papers_search(&self, Parameters(p): Parameters<SearchParams>) -> String {
228 self.run_search(&p, Category::Papers).await
229 }
230
231 #[tool(
232 description = "Search Internet Archive media (texts, audio, video, software). Returns item title, url and description."
233 )]
234 async fn archives_search(&self, Parameters(p): Parameters<SearchParams>) -> String {
235 self.run_search(&p, Category::Archives).await
236 }
237
238 #[tool(
239 description = "Fetch a URL and extract its readable main content. Use for grounding answers on the sources returned by web_search."
240 )]
241 async fn fetch_page(&self, Parameters(p): Parameters<FetchParams>) -> String {
242 match phrona::extract(
243 self.client.http(),
244 &p.url,
245 p.max_chars.unwrap_or(8000).clamp(1, 100_000),
246 p.query.as_deref(),
247 )
248 .await
249 {
250 Ok(page) => envelope(serde_json::json!({
251 "url": page.url,
252 "title": page.title,
253 "description": page.description,
254 "text": page.text,
255 })),
256 Err(e) => envelope(serde_json::json!({"error": e.to_string()})),
257 }
258 }
259
260 #[tool(description = "Get query suggestions for auto-completion from search engines.")]
261 async fn suggest(&self, Parameters(p): Parameters<SuggestParams>) -> String {
262 let region = p.region.as_deref().unwrap_or("us-en");
263 match &p.source {
264 Some(name) => {
265 let Some(source) = phrona::SuggestSource::from_name(name) else {
266 return envelope(serde_json::json!({
267 "query": p.query,
268 "suggestions": [],
269 "error": format!("unknown source '{name}'"),
270 }));
271 };
272 match phrona::suggest(self.client.http(), source, &p.query, region).await {
273 Ok(list) => envelope(serde_json::json!({
274 "query": p.query,
275 "source": name,
276 "suggestions": list,
277 })),
278 Err(e) => envelope(serde_json::json!({
279 "query": p.query,
280 "suggestions": [],
281 "error": e.to_string(),
282 })),
283 }
284 }
285 None => {
286 let all = phrona::suggest_all(self.client.http(), &p.query, region).await;
287 let map: serde_json::Map<String, _> = all
288 .into_iter()
289 .map(|(s, list)| (s.name().to_string(), serde_json::json!(list)))
290 .collect();
291 envelope(serde_json::json!({"query": p.query, "suggestions": map}))
292 }
293 }
294 }
295
296 #[tool(
297 description = "List the search engines available per category. Pass engine names to other tools to restrict them."
298 )]
299 fn list_engines(&self, Parameters(p): Parameters<EnginesParams>) -> String {
300 let cats: Vec<Category> = match p.category.as_deref() {
301 Some(c) => match c.parse::<Category>() {
304 Ok(c) => vec![c],
305 Err(_) => {
306 return envelope(serde_json::json!({
307 "error": format!(
308 "invalid category '{c}', expected one of: {}",
309 Category::list_str()
310 )
311 }));
312 }
313 },
314 None => Category::ALL.to_vec(),
315 };
316 let mut out = serde_json::Map::new();
317 for cat in cats {
318 let names: Vec<String> = phrona::available_engines(cat)
319 .iter()
320 .map(|e| e.name.clone())
321 .collect();
322 out.insert(cat.as_str().to_string(), serde_json::json!(names));
323 }
324 envelope(serde_json::json!({"engines": out}))
325 }
326
327 #[tool(
328 description = "Grounded web search for RAG: returns a synthesized answer plus ranked sources with content. Web category only; use the *_search tools plus fetch_page for other categories. Prefer this over web_search + fetch_page for single-shot questions."
329 )]
330 async fn search_grounded(&self, Parameters(p): Parameters<SearchParams>) -> String {
331 let opts = match Self::build_opts(&p, Category::Web, self.max_results_limit) {
332 Ok(opts) => opts,
333 Err(msg) => {
334 return envelope(serde_json::json!({
335 "query": p.query,
336 "answer": "",
337 "sources": [],
338 "error": msg,
339 }));
340 }
341 };
342 match self.client.search(opts).await {
343 Ok(resp) => {
344 let sources: Vec<serde_json::Value> = resp
345 .results
346 .iter()
347 .enumerate()
348 .filter_map(|(i, r)| {
349 let (title, url, content) = match r {
350 ResultItem::Web(w) => (&w.title, &w.url, &w.description),
351 ResultItem::News(n) => (&n.title, &n.url, &n.description),
352 ResultItem::Video(v) => (&v.title, &v.url, &v.description),
353 ResultItem::Image(im) => (&im.title, &im.url, &im.source),
354 ResultItem::Book(b) => (&b.title, &b.url, &b.info),
355 };
356 if content.is_empty() {
357 return None;
358 }
359 Some(serde_json::json!({
360 "title": title,
361 "url": url,
362 "content": content,
363 "score": phrona::rank::positional_score(i),
364 }))
365 })
366 .collect();
367 let answer = resp.answer.clone().unwrap_or_else(|| {
368 format!(
369 "Found {} sources for \"{}\". Inspect the sources for the full picture.",
370 sources.len(),
371 resp.query
372 )
373 });
374 envelope(serde_json::json!({
375 "query": resp.query,
376 "answer": answer,
377 "sources": sources,
378 }))
379 }
380 Err(e) => envelope(serde_json::json!({
381 "query": p.query,
382 "answer": "",
383 "sources": [],
384 "error": e.to_string(),
385 })),
386 }
387 }
388}
389
390pub async fn run_stdio(cfg: &PhronaConfig) -> anyhow::Result<()> {
393 let service = PhronaMcp::with_config(cfg);
394 let server = service.serve(stdio()).await?;
395 let _ = server.waiting().await?;
396 Ok(())
397}
398
399pub async fn serve_tcp(
405 listener: tokio::net::TcpListener,
406 cfg: PhronaConfig,
407 shutdown: std::sync::Arc<tokio::sync::Notify>,
408) -> anyhow::Result<()> {
409 tracing::info!("phrona-mcp listening on {}", listener.local_addr()?);
410 let service = PhronaMcp::with_config(&cfg);
413 let mut conns: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
414 loop {
415 tokio::select! {
416 _ = shutdown.notified() => break,
417 accepted = listener.accept() => {
418 let (socket, _) = accepted?;
419 let service = service.clone();
420 conns.spawn(async move {
421 match service.serve(socket).await {
422 Ok(server) => {
423 let _ = server.waiting().await;
424 }
425 Err(e) => tracing::debug!("mcp connection failed: {e}"),
426 }
427 });
428 }
429 }
430 }
431 let drain = tokio::time::timeout(Duration::from_secs(2), conns.join_all());
434 let _ = drain.await;
435 Ok(())
436}
437
438pub async fn tcp_listener(addr: &str) -> anyhow::Result<tokio::net::TcpListener> {
440 Ok(tokio::net::TcpListener::bind(addr).await?)
441}
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446
447 fn params(query: &str) -> SearchParams {
448 SearchParams {
449 query: query.to_string(),
450 engines: None,
451 max_results: None,
452 region: None,
453 language: None,
454 time_range: None,
455 safesearch: None,
456 filters: None,
457 page: None,
458 }
459 }
460
461 #[test]
462 fn build_opts_defaults() {
463 let opts = PhronaMcp::build_opts(¶ms("rust"), Category::Web, 100).unwrap();
464 assert_eq!(opts.category, Category::Web);
465 assert_eq!(opts.max_results, 20);
466 assert_eq!(opts.safesearch, phrona::SafeSearch::Moderate);
467 assert_eq!(opts.page, 1);
468 }
469
470 #[test]
471 fn build_opts_clamps_and_maps() {
472 let mut p = params("rust");
473 p.max_results = Some(5000);
474 p.page = Some(0);
475 let opts = PhronaMcp::build_opts(&p, Category::News, 50).unwrap();
476 assert_eq!(opts.max_results, 50);
477 assert_eq!(opts.page, 1);
478 assert_eq!(opts.category, Category::News);
479 }
480
481 #[test]
482 fn build_opts_rejects_bad_enums() {
483 let mut p = params("rust");
484 p.time_range = Some("yesterday".into());
485 assert!(PhronaMcp::build_opts(&p, Category::Web, 100).is_err());
486 p.time_range = None;
487 p.safesearch = Some("medium".into());
488 assert!(PhronaMcp::build_opts(&p, Category::Web, 100).is_err());
489 p.safesearch = Some("strict".into());
490 assert!(PhronaMcp::build_opts(&p, Category::Web, 100).is_ok());
491 }
492
493 #[test]
494 fn envelope_is_always_valid_json() {
495 let v: serde_json::Value = serde_json::from_str(&envelope(serde_json::json!({
496 "query": "rust",
497 "total": 0,
498 "results": [],
499 })))
500 .unwrap();
501 assert_eq!(v["query"], "rust");
502 assert_eq!(v["total"], 0);
503 assert!(v["results"].is_array());
504 }
505
506 #[tokio::test]
507 async fn tool_errors_return_json_envelopes_with_query_total_results() {
508 let mut p = params("rust");
509 p.time_range = Some("bogus".into());
510 let out = PhronaMcp::with_config(&PhronaConfig::defaults())
511 .run_search(&p, Category::Web)
512 .await;
513 let v: serde_json::Value = serde_json::from_str(&out).expect("tool output is JSON");
514 assert_eq!(v["query"], "rust");
515 assert_eq!(v["total"], 0);
516 assert!(v["results"].is_array());
517 assert!(
518 v["error"]
519 .as_str()
520 .is_some_and(|e| e.contains("time_range"))
521 );
522 }
523}