Skip to main content

phrona_mcp/
lib.rs

1//! Phrona MCP server.
2//!
3//! Exposes the phrona library to AI agents over stdio (JSON-RPC).
4//! Tools are compartmentalized per capability: per-category search,
5//! suggestions, page extraction and grounded search for RAG.
6
7#![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    /// Build the server from a typed config: profile, timeout, proxies and
97    /// the `max_results` clamp all come from it.
98    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    /// Map tool arguments to search options; invalid enums are rejected
106    /// loudly instead of silently coerced.
107    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            // `max(1)`: a zero operator limit must clamp, not panic in `clamp`
124            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
172/// Serialize a tool result as a single-line JSON string. MCP stdio clients
173/// parse tool outputs as JSON, so every handler must return valid JSON —
174/// never an empty string or a debug-formatted value. A serialization
175/// failure (theoretically unreachable for `serde_json::Value`) degrades to
176/// a fixed JSON envelope.
177fn 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            // invalid categories are a loud error (matching the REST API),
302            // not an empty result that looks like success
303            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
390/// Serve the MCP server over stdio (JSON-RPC 2.0, newline-delimited).
391/// Blocks until the client disconnects.
392pub 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
399/// Serve the MCP server over a TCP listener (newline-delimited JSON-RPC,
400/// the same framing as stdio). Each connection is served in its own task.
401/// `shutdown` fires on SIGTERM/Ctrl+C: the accept loop stops and in-flight
402/// connections get a short grace window to drain before the process exits.
403/// Blocks until the listener is closed or shutdown fires.
404pub 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    // one service for every connection: per-connection builds would redo
411    // the whole client pool (connection pools, cookie jars) each time
412    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    // Grace window for in-flight requests; the rest are aborted with the
432    // process when the runtime shuts down.
433    let drain = tokio::time::timeout(Duration::from_secs(2), conns.join_all());
434    let _ = drain.await;
435    Ok(())
436}
437
438/// Build a TCP listener from an addr string (e.g. "127.0.0.1:8081").
439pub 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(&params("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}