Skip to main content

rss_cli/
mcp.rs

1//! Model Context Protocol server (stdio transport). **Owner: `mcp` agent.**
2//!
3//! Runs `rss mcp`: exposes the same core operations as MCP tools so agents can call the
4//! tool directly. Implemented with `rmcp` 2.x (`#[tool]` / `#[tool_router]` macros,
5//! `serve(stdio())`).
6//!
7//! ## Requirements
8//! - Expose tools that delegate to [`crate::core`] (do **not** reimplement fetch/parse):
9//!   - `fetch_feed { url, content_format?, limit?, since? }` → a single feed's `FeedResult`
10//!     (or the full `FetchOutput` for multiple urls).
11//!   - `discover_feeds { site_url }` → `DiscoverOutput`.
12//!   - `get_item { feed_url, id }` → one `Item` (fetch + find by stable id).
13//!   - `get_schema { command }` → the JSON Schema from [`crate::output::schema_for`].
14//! - Tool results are JSON (serialize the model types).
15//! - **All logging/diagnostics must go to stderr** — stdout is the MCP transport.
16//! - Build the [`Cache`](crate::cache::Cache) once and share it across tool calls.
17
18use rmcp::handler::server::router::tool::ToolRouter;
19use rmcp::handler::server::wrapper::Parameters;
20use rmcp::model::{CallToolResult, ContentBlock, Implementation, ServerCapabilities, ServerInfo};
21use rmcp::{ErrorData, ServerHandler, ServiceExt, tool, tool_handler, tool_router};
22use schemars::JsonSchema;
23use serde::{Deserialize, Deserializer, Serialize};
24
25use crate::cache::Cache;
26use crate::config::{CachePolicy, FetchParams};
27use crate::core;
28use crate::error::RssError;
29use crate::fetch::HttpClient;
30use crate::model::{ContentFormat, ErrorObj};
31use crate::output;
32
33/// Human-readable guidance surfaced to MCP clients during `initialize`.
34const SERVER_INSTRUCTIONS: &str = "\
35AI-friendly RSS/Atom tools. All tools return JSON text matching the rss-cli output \
36contract (use get_schema for the authoritative shapes). fetch_feed retrieves and parses a \
37feed; discover_feeds finds feeds advertised on a website; get_item returns a single item by \
38its stable id; get_schema returns the JSON Schema for the 'fetch' or 'discover' output. \
39Responses are size-bounded: fetch_feed caps items (default 25) and rejects oversized results \
40with a RESPONSE_TOO_LARGE error carrying suggested limit/max_content_chars to retry with.";
41
42/// Default item cap `fetch_feed` applies when the caller passes no `limit`. Bounds the common
43/// "too many items" blow-up (e.g. a hot post's comment feed) without the caller opting in.
44const MCP_DEFAULT_LIMIT: usize = 25;
45
46/// Default response budget (estimated tokens) when the caller passes no `max_response_tokens`.
47/// Conservative headroom under typical MCP client tool-result limits; the `ceil(chars/4)`
48/// estimate (over *pretty* JSON) errs toward over-counting. Overridable per call.
49///
50/// Calibrated down from an initial 20k after a field report: a full-content 25-item feed
51/// (~50–60 KB) slipped under the 20k budget yet still tripped the client's own tool-result
52/// limit, which then dumped the payload to a temp file. 10k (~40 KB) keeps the structured
53/// `RESPONSE_TOO_LARGE` self-recovery path (ADR-0011) firing *before* the client truncates,
54/// while staying generous for ordinary feeds. This is a heuristic, not a measured universal
55/// client limit — callers with a different limit pass `max_response_tokens` explicitly.
56const MCP_DEFAULT_MAX_RESPONSE_TOKENS: usize = 10_000;
57
58/// Deserialize an optional `usize` that may arrive as a JSON **number** *or* a JSON
59/// **string** (`25` or `"25"`); `null`/absent both map to `None`. Many MCP clients serialize
60/// every tool-call argument as a string, so a plain `Option<usize>` rejects `"25"` with
61/// `invalid type: string "25", expected usize` and makes `limit` / `max_content_chars` /
62/// `max_response_tokens` unusable from those clients. We still advertise `integer` in the
63/// tool schema (schemars reads the field *type*, not this attribute) but accept either form —
64/// liberal in what we accept, strict in what we advertise.
65///
66/// Do **not** "simplify" the annotated fields back to a bare `Option<usize>`: that
67/// reintroduces the string-rejection bug for every client that stringifies arguments.
68fn de_lenient_opt_usize<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
69where
70    D: Deserializer<'de>,
71{
72    use serde::de::Error;
73    match Option::<serde_json::Value>::deserialize(deserializer)? {
74        None | Some(serde_json::Value::Null) => Ok(None),
75        Some(serde_json::Value::Number(n)) => {
76            if let Some(u) = n.as_u64() {
77                usize::try_from(u)
78                    .map(Some)
79                    .map_err(|_| Error::custom(format!("integer {u} is out of range")))
80            } else if let Some(f) = n
81                .as_f64()
82                .filter(|f| f.is_finite() && *f >= 0.0 && f.fract() == 0.0)
83            {
84                Ok(Some(f as usize))
85            } else {
86                Err(Error::custom(format!(
87                    "expected a non-negative integer, got {n}"
88                )))
89            }
90        }
91        Some(serde_json::Value::String(s)) => {
92            let trimmed = s.trim();
93            if trimmed.is_empty() {
94                return Ok(None);
95            }
96            trimmed.parse::<usize>().map(Some).map_err(|_| {
97                Error::custom(format!(
98                    "expected a non-negative integer (or its string form), got {s:?}"
99                ))
100            })
101        }
102        Some(other) => Err(Error::custom(format!(
103            "expected an integer or numeric string, got {other}"
104        ))),
105    }
106}
107
108// === Tool argument structs (deserialized from MCP `arguments`; schema'd for clients) ===
109
110/// Arguments for the `fetch_feed` tool.
111#[derive(Debug, Deserialize, JsonSchema)]
112struct FetchFeedArgs {
113    /// The RSS/Atom feed URL to fetch and parse.
114    url: String,
115    /// Content extraction format: `markdown` (default), `text`, `html`, or `none`.
116    #[serde(default)]
117    content_format: Option<String>,
118    /// Maximum number of items to return (most recent first). Omit to use the default cap
119    /// of 25; pass a larger number to fetch more (subject to the response budget).
120    #[serde(default, deserialize_with = "de_lenient_opt_usize")]
121    limit: Option<usize>,
122    /// Maximum characters of extracted content per item; longer bodies are truncated on a
123    /// char boundary and flagged `content_truncated`. Omit to keep full content.
124    #[serde(default, deserialize_with = "de_lenient_opt_usize")]
125    max_content_chars: Option<usize>,
126    /// Soft cap on response size in estimated tokens. If the result would exceed it, the
127    /// tool returns a RESPONSE_TOO_LARGE error with suggested `limit`/`max_content_chars`
128    /// instead of an oversized payload. Omit to use the default budget.
129    #[serde(default, deserialize_with = "de_lenient_opt_usize")]
130    max_response_tokens: Option<usize>,
131}
132
133/// Arguments for the `discover_feeds` tool.
134#[derive(Debug, Deserialize, JsonSchema)]
135struct DiscoverFeedsArgs {
136    /// The website URL to scan for advertised feeds.
137    site_url: String,
138}
139
140/// Arguments for the `get_item` tool.
141#[derive(Debug, Deserialize, JsonSchema)]
142struct GetItemArgs {
143    /// The feed URL that contains the item.
144    feed_url: String,
145    /// The item key: its stable `id`, raw `guid` (e.g. Reddit `t3_…`/`t1_…`), or permalink
146    /// URL. A guid is the reliable key across different feed URLs, since `id` is namespaced
147    /// by `feed_url`. Served cache-first: an item from a prior `fetch_feed` survives a rolled
148    /// feed window, but not a later refetch that overwrote the cache.
149    id: String,
150    /// Maximum characters of extracted content; a longer body is truncated and flagged.
151    /// Omit for full content (use this if a single large item is rejected as too large).
152    #[serde(default, deserialize_with = "de_lenient_opt_usize")]
153    max_content_chars: Option<usize>,
154}
155
156/// Arguments for the `get_schema` tool.
157#[derive(Debug, Deserialize, JsonSchema)]
158struct GetSchemaArgs {
159    /// Which command's output schema to return: `fetch` or `discover`.
160    command: String,
161}
162
163/// MCP server state: the shared, cheaply-cloneable HTTP cache and HTTP client plus the
164/// generated tool router. Built once in [`serve_stdio`] and shared across all tool calls —
165/// sharing the client is what lets concurrent `fetch_feed` calls coordinate their per-host
166/// pacing (ADR-0016); a fresh client per call could not.
167#[derive(Clone)]
168struct RssServer {
169    cache: Cache,
170    http: HttpClient,
171    tool_router: ToolRouter<Self>,
172}
173
174impl RssServer {
175    fn new(cache: Cache, http: HttpClient) -> Self {
176        Self {
177            cache,
178            http,
179            tool_router: Self::tool_router(),
180        }
181    }
182}
183
184#[tool_router]
185impl RssServer {
186    /// Fetch and parse a single RSS/Atom feed. Returns the full `FetchOutput` (one entry in
187    /// `feeds`), so feed-level errors surface as a `FeedStatus::Error` entry rather than a
188    /// tool failure.
189    ///
190    /// Note: the frozen module doc sketches `{ ..., since? }` returning a single `FeedResult`;
191    /// the assigned task spec supersedes that — args are `{ url, content_format?, limit? }` and
192    /// we return the whole `FetchOutput`.
193    #[tool(
194        description = "Fetch and parse an RSS/Atom feed by URL. Returns the FetchOutput as \
195        structured content (and a one-line text summary); schema: get_schema command=fetch. \
196        content_format is one of markdown|text|html|none; limit caps items, newest first \
197        (DEFAULT 25 when omitted). max_content_chars truncates each item body (flagged \
198        content_truncated). The response is size-bounded by max_response_tokens; if it would \
199        overflow, the tool returns a RESPONSE_TOO_LARGE error whose details include \
200        suggested_limit and suggested_max_content_chars to retry with. Provider notes: some \
201        feeds (e.g. Reddit comment .rss) populate only updated, not published, and append the \
202        original post to a comment listing (so a comment feed can return one more item than \
203        limit); search.rss results are best-effort and may be sparse.",
204        annotations(read_only_hint = true, idempotent_hint = true, open_world_hint = true),
205        output_schema = rmcp::handler::server::tool::schema_for_type::<crate::model::FetchOutput>()
206    )]
207    async fn fetch_feed(
208        &self,
209        Parameters(args): Parameters<FetchFeedArgs>,
210    ) -> Result<CallToolResult, ErrorData> {
211        Ok(fetch_feed_inner(&self.http, &self.cache, args).await)
212    }
213
214    /// Discover feeds advertised on a website's homepage.
215    #[tool(
216        description = "Discover RSS/Atom/JSON feeds advertised on a website. Returns the \
217        DiscoverOutput as structured content (schema: get_schema command=discover).",
218        annotations(read_only_hint = true, idempotent_hint = true, open_world_hint = true),
219        output_schema = rmcp::handler::server::tool::schema_for_type::<crate::model::DiscoverOutput>()
220    )]
221    async fn discover_feeds(
222        &self,
223        Parameters(args): Parameters<DiscoverFeedsArgs>,
224    ) -> Result<CallToolResult, ErrorData> {
225        match core::discover_feeds(&args.site_url, &FetchParams::default()).await {
226            Ok(out) => {
227                let summary = format!(
228                    "Discovered {} feed(s) at {}.",
229                    out.feeds.len(),
230                    args.site_url
231                );
232                Ok(structured_result(&out, summary))
233            }
234            Err(e) => Ok(tool_error_obj(&e, Some(&args.site_url))),
235        }
236    }
237
238    /// Fetch a feed and return the single item whose stable id matches `id`.
239    #[tool(
240        description = "Fetch a feed and return the single Item matching a stable id (from \
241        fetch_feed) as structured content, or an error if the id is not present. \
242        max_content_chars truncates the body; a single oversized item (e.g. a hot comment \
243        thread) returns RESPONSE_TOO_LARGE with a suggested_max_content_chars to retry with.",
244        annotations(read_only_hint = true, idempotent_hint = true, open_world_hint = true),
245        output_schema = rmcp::handler::server::tool::schema_for_type::<crate::model::Item>()
246    )]
247    async fn get_item(
248        &self,
249        Parameters(args): Parameters<GetItemArgs>,
250    ) -> Result<CallToolResult, ErrorData> {
251        Ok(get_item_inner(&self.cache, args).await)
252    }
253
254    /// Return the authoritative JSON Schema for a command's output.
255    #[tool(
256        description = "Return the authoritative JSON Schema for a command's output. \
257        command is 'fetch' or 'discover'.",
258        annotations(read_only_hint = true, idempotent_hint = true, open_world_hint = false)
259    )]
260    async fn get_schema(
261        &self,
262        Parameters(args): Parameters<GetSchemaArgs>,
263    ) -> Result<CallToolResult, ErrorData> {
264        let schema = output::schema_for(&args.command);
265        if schema.is_null() {
266            return Ok(tool_error_code(
267                "USAGE_ERROR",
268                format!(
269                    "unknown command '{}' (expected 'fetch' or 'discover')",
270                    args.command
271                ),
272                None,
273            ));
274        }
275        Ok(json_result(&schema))
276    }
277}
278
279#[tool_handler(router = self.tool_router)]
280impl ServerHandler for RssServer {
281    fn get_info(&self) -> ServerInfo {
282        ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
283            .with_server_info(Implementation::new(
284                env!("CARGO_PKG_NAME"),
285                env!("CARGO_PKG_VERSION"),
286            ))
287            .with_instructions(SERVER_INSTRUCTIONS)
288    }
289}
290
291/// Core of the `fetch_feed` tool, free of the `#[tool]` macro plumbing so it is directly
292/// unit-testable. Applies the default item cap, the per-item content cap, and the response
293/// budget, attaching a [`crate::model::TruncationInfo`] marker when content was truncated.
294async fn fetch_feed_inner(http: &HttpClient, cache: &Cache, args: FetchFeedArgs) -> CallToolResult {
295    let mut params = FetchParams::default();
296    if let Some(cf) = args.content_format.as_deref() {
297        match parse_content_format(cf) {
298            Some(fmt) => params.content_format = fmt,
299            None => {
300                return tool_error_code(
301                    "USAGE_ERROR",
302                    format!("invalid content_format '{cf}' (expected markdown|text|html|none)"),
303                    Some(&args.url),
304                );
305            }
306        }
307    }
308    // Apply a default item cap so a single huge feed doesn't blow the response budget.
309    params.limit = Some(args.limit.unwrap_or(MCP_DEFAULT_LIMIT));
310    params.max_content_chars = args.max_content_chars;
311
312    let mut out =
313        core::fetch_feeds_with(std::slice::from_ref(&args.url), &params, cache, http).await;
314
315    // Reject oversized results with an actionable error rather than letting the client trip
316    // its own tool-result-too-large limit on an opaque failure.
317    let budget = args
318        .max_response_tokens
319        .unwrap_or(MCP_DEFAULT_MAX_RESPONSE_TOKENS);
320    match core::enforce_response_budget(&out, budget) {
321        Ok(estimated) => {
322            // A marker is emitted only when content was actually truncated; the default cap
323            // is documented in the tool description, so an untruncated response stays clean.
324            if let Some(mut marker) = core::truncation_marker(
325                &out,
326                params.limit,
327                Some(
328                    "content was truncated; call get_item for the full body of a specific item"
329                        .to_string(),
330                ),
331            ) {
332                marker.estimated_tokens = Some(estimated);
333                out.truncation = Some(marker);
334            }
335            let summary = fetch_summary(&out, &args.url);
336            structured_result(&out, summary)
337        }
338        Err(e) => tool_error_obj(&e, Some(&args.url)),
339    }
340}
341
342/// A one-line, human/agent-readable summary of a `fetch_feed` result. Kept terse on purpose:
343/// it is the *unstructured* `content` companion to the full `structured_content` payload, so
344/// we don't duplicate the whole FetchOutput as text (which would ~double the response and
345/// undercut the token budget — see ADR-0011/ADR-0013).
346fn fetch_summary(out: &crate::model::FetchOutput, url: &str) -> String {
347    match out.feeds.first() {
348        Some(feed) if feed.error.is_some() => {
349            let code = feed
350                .error
351                .as_ref()
352                .map(|e| e.code.as_str())
353                .unwrap_or("ERROR");
354            format!("fetch_feed: {url} returned an error ({code}); see structuredContent.")
355        }
356        Some(feed) => {
357            let title = feed.title.as_deref().unwrap_or(url);
358            let mut s = format!(
359                "Fetched {} item(s) (~{} content tokens) from \"{title}\".",
360                out.total_items, out.total_content_tokens_est
361            );
362            if out.truncation.is_some() {
363                s.push_str(" Content truncated (see structuredContent.truncation).");
364            }
365            s.push_str(" Full data in structuredContent.");
366            s
367        }
368        None => format!("fetch_feed: no result for {url}."),
369    }
370}
371
372/// Core of the `get_item` tool, free of the `#[tool]` macro plumbing. Guards the
373/// full-content escape hatch: a single item that still exceeds the budget yields a
374/// `RESPONSE_TOO_LARGE` error rather than tripping the client limit.
375async fn get_item_inner(cache: &Cache, args: GetItemArgs) -> CallToolResult {
376    let params = FetchParams {
377        max_content_chars: args.max_content_chars,
378        cache_policy: CachePolicy::CacheFirst,
379        ..FetchParams::default()
380    };
381    match core::show_item(&args.feed_url, &args.id, &params, cache).await {
382        Ok(Some(item)) => {
383            let estimated = serde_json::to_string_pretty(&item)
384                .map(|s| s.chars().count().div_ceil(4))
385                .unwrap_or(0);
386            if estimated > MCP_DEFAULT_MAX_RESPONSE_TOKENS {
387                let suggested = (MCP_DEFAULT_MAX_RESPONSE_TOKENS * 7 / 10)
388                    .saturating_mul(4)
389                    .max(200);
390                let err = RssError::ResponseTooLarge {
391                    estimated_tokens: estimated,
392                    budget_tokens: MCP_DEFAULT_MAX_RESPONSE_TOKENS,
393                    suggested_limit: 1,
394                    suggested_max_content_chars: suggested,
395                };
396                return tool_error_obj(&err, Some(&args.feed_url));
397            }
398            let summary = format!(
399                "Item {}: \"{}\".",
400                item.id,
401                item.title.as_deref().unwrap_or("(untitled)")
402            );
403            structured_result(&item, summary)
404        }
405        Ok(None) => tool_error_code(
406            "NOT_FOUND",
407            format!("item '{}' not found in {}", args.id, args.feed_url),
408            Some(&args.feed_url),
409        ),
410        Err(e) => tool_error_obj(&e, Some(&args.feed_url)),
411    }
412}
413
414/// Serialize a value as pretty JSON and wrap it in a successful tool result.
415///
416/// Serialization of our own model types cannot realistically fail, but if it ever does we
417/// surface it as a tool error rather than panicking the server.
418fn json_result<T: Serialize>(value: &T) -> CallToolResult {
419    match serde_json::to_string_pretty(value) {
420        Ok(json) => CallToolResult::success(vec![ContentBlock::text(json)]),
421        Err(e) => tool_error_code(
422            "INTERNAL_ERROR",
423            format!("failed to serialize result: {e}"),
424            None,
425        ),
426    }
427}
428
429/// Wrap `value` as the tool's **structured** result: the typed data goes in
430/// `structured_content` (machine-readable, matching the tool's `output_schema`) and only a
431/// short `summary` line goes in the unstructured `content`. We deliberately do *not* also
432/// serialize the full value as text — that would double the payload and undercut the
433/// response budget (see ADR-0013). `CallToolResult` is `#[non_exhaustive]`, so we mutate the
434/// public `structured_content` field on an owned `success` result rather than struct-literal.
435fn structured_result<T: Serialize>(value: &T, summary: impl Into<String>) -> CallToolResult {
436    match serde_json::to_value(value) {
437        Ok(json) => {
438            let mut result = CallToolResult::success(vec![ContentBlock::text(summary.into())]);
439            result.structured_content = Some(json);
440            result
441        }
442        Err(e) => tool_error_code(
443            "INTERNAL_ERROR",
444            format!("failed to serialize result: {e}"),
445            None,
446        ),
447    }
448}
449
450/// Build a tool-level error result (`is_error: true`) from an [`RssError`], serializing the
451/// structured [`ErrorObj`] (stable `code` + machine-readable `details`, e.g. the
452/// `suggested_*` fields on `RESPONSE_TOO_LARGE`) as JSON so the agent can parse and recover.
453fn tool_error_obj(err: &RssError, feed_url: Option<&str>) -> CallToolResult {
454    error_result(err.to_error_obj(feed_url))
455}
456
457/// Build a structured tool error from an explicit code + message, for argument/validation
458/// failures that don't correspond to an [`RssError`] variant.
459fn tool_error_code(
460    code: &str,
461    message: impl Into<String>,
462    feed_url: Option<&str>,
463) -> CallToolResult {
464    let mut obj = ErrorObj::new(code, message);
465    if let Some(u) = feed_url {
466        obj.feed_url = Some(u.to_string());
467    }
468    error_result(obj)
469}
470
471/// Serialize an [`ErrorObj`] as JSON and wrap it in a failed tool result.
472fn error_result(obj: ErrorObj) -> CallToolResult {
473    let json = serde_json::to_string_pretty(&obj).unwrap_or_else(|_| {
474        format!(
475            "{{\"code\":\"{}\",\"message\":\"{}\"}}",
476            obj.code, obj.message
477        )
478    });
479    CallToolResult::error(vec![ContentBlock::text(json)])
480}
481
482/// Parse a user-supplied content-format string into a [`ContentFormat`]. Case-insensitive.
483fn parse_content_format(s: &str) -> Option<ContentFormat> {
484    match s.trim().to_ascii_lowercase().as_str() {
485        "markdown" => Some(ContentFormat::Markdown),
486        "text" => Some(ContentFormat::Text),
487        "html" => Some(ContentFormat::Html),
488        "none" => Some(ContentFormat::None),
489        _ => None,
490    }
491}
492
493/// Run the MCP server over stdio until the client disconnects. **Owner: `mcp` agent.**
494pub async fn serve_stdio(cache: Cache) -> Result<(), RssError> {
495    // One shared HTTP client (and its per-host gate + connection pool) for every tool call.
496    let params = FetchParams::default();
497    let http = HttpClient::new(&params.user_agent, params.timeout)?;
498    let server = RssServer::new(cache, http);
499    tracing::info!("starting MCP server on stdio");
500
501    let service = server
502        .serve(rmcp::transport::stdio())
503        .await
504        .map_err(|e| RssError::Other(format!("failed to start MCP server: {e}")))?;
505
506    let quit_reason = service
507        .waiting()
508        .await
509        .map_err(|e| RssError::Other(format!("MCP server error: {e}")))?;
510    tracing::info!(?quit_reason, "MCP server stopped");
511    Ok(())
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    /// Decode a `CallToolResult` into `(is_error, inner_payload_json)`. We serialize the
519    /// whole result and read the MCP wire fields. Success results now carry the typed data in
520    /// `structuredContent` (with only a summary in `content`); error results carry the
521    /// `ErrorObj` JSON as text. Prefer `structuredContent` when present, else parse the text.
522    fn decode(result: &CallToolResult) -> (bool, serde_json::Value) {
523        let v = serde_json::to_value(result).expect("serialize CallToolResult");
524        let is_error = v
525            .get("isError")
526            .or_else(|| v.get("is_error"))
527            .and_then(|b| b.as_bool())
528            .unwrap_or(false);
529        if let Some(sc) = v.get("structuredContent")
530            && !sc.is_null()
531        {
532            return (is_error, sc.clone());
533        }
534        let text = v["content"][0]["text"]
535            .as_str()
536            .unwrap_or_else(|| panic!("expected text content, got: {v}"));
537        let payload = serde_json::from_str(text)
538            .unwrap_or_else(|e| panic!("tool text should be JSON ({e}): {text}"));
539        (is_error, payload)
540    }
541
542    /// Pull the unstructured `content[0].text` summary out of a result, if any.
543    fn summary_text(result: &CallToolResult) -> Option<String> {
544        let v = serde_json::to_value(result).ok()?;
545        v["content"][0]["text"].as_str().map(|s| s.to_string())
546    }
547
548    /// A fresh HTTP client for a test (its own per-host gate, so tests never couple).
549    fn test_http() -> HttpClient {
550        HttpClient::new("rss-cli-test", std::time::Duration::from_secs(10)).expect("build client")
551    }
552
553    fn temp_cache(tag: &str) -> (Cache, std::path::PathBuf) {
554        let dir = std::env::temp_dir().join(format!(
555            "rss-mcp-test-{}-{tag}-{:?}",
556            std::process::id(),
557            std::time::SystemTime::now()
558                .duration_since(std::time::UNIX_EPOCH)
559                .map(|d| d.as_nanos())
560                .unwrap_or(0)
561        ));
562        std::fs::create_dir_all(&dir).expect("create temp cache");
563        (Cache::open(Some(dir.clone())).expect("open cache"), dir)
564    }
565
566    fn feed_with_items(n: usize) -> String {
567        let mut items = String::new();
568        for i in 0..n {
569            items.push_str(&format!(
570                "<item><title>Post {i}</title><link>https://example.com/{i}</link>\
571                 <description>body number {i}</description></item>"
572            ));
573        }
574        format!(
575            "<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>Feed</title>\
576             <link>https://example.com/</link>{items}</channel></rss>"
577        )
578    }
579
580    fn fetch_args(url: String) -> FetchFeedArgs {
581        FetchFeedArgs {
582            url,
583            content_format: None,
584            limit: None,
585            max_content_chars: None,
586            max_response_tokens: None,
587        }
588    }
589
590    #[tokio::test]
591    async fn fetch_feed_applies_default_item_cap() {
592        let mut server = mockito::Server::new_async().await;
593        let _m = server
594            .mock("GET", "/feed.xml")
595            .with_status(200)
596            .with_body(feed_with_items(30))
597            .create_async()
598            .await;
599        let (cache, dir) = temp_cache("cap");
600
601        let result = fetch_feed_inner(
602            &test_http(),
603            &cache,
604            fetch_args(format!("{}/feed.xml", server.url())),
605        )
606        .await;
607        let (is_error, payload) = decode(&result);
608
609        assert!(!is_error, "a normal feed should succeed: {payload}");
610        let items = payload["feeds"][0]["items"].as_array().expect("items");
611        assert_eq!(
612            items.len(),
613            MCP_DEFAULT_LIMIT,
614            "fetch_feed should cap to the default {MCP_DEFAULT_LIMIT} items when no limit is passed"
615        );
616        // Nothing was content-truncated, so the marker stays null (cap is documented, not noise).
617        assert!(
618            payload["truncation"].is_null(),
619            "untruncated result → truncation null"
620        );
621
622        std::fs::remove_dir_all(&dir).ok();
623    }
624
625    #[tokio::test]
626    async fn concurrent_fetches_share_one_client_and_gate() {
627        // Pins the reported bug: two concurrent fetch_feed calls go through ONE shared
628        // HttpClient (hence one per-host gate), the way RssServer wires them — not a fresh
629        // client per call. Both succeed here; the gate's serialize/cooldown behavior is
630        // unit-tested in `ratelimit`.
631        let mut server = mockito::Server::new_async().await;
632        let _m = server
633            .mock("GET", "/feed.xml")
634            .with_status(200)
635            .with_body(feed_with_items(2))
636            .create_async()
637            .await;
638        let (cache, dir) = temp_cache("shared-client");
639        let http = test_http();
640        let url = format!("{}/feed.xml", server.url());
641
642        let (r1, r2) = tokio::join!(
643            fetch_feed_inner(&http, &cache, fetch_args(url.clone())),
644            fetch_feed_inner(&http, &cache, fetch_args(url.clone())),
645        );
646        for r in [&r1, &r2] {
647            let (is_error, payload) = decode(r);
648            assert!(
649                !is_error,
650                "shared-client concurrent fetch should succeed: {payload}"
651            );
652        }
653
654        std::fs::remove_dir_all(&dir).ok();
655    }
656
657    #[test]
658    fn tools_advertise_annotations_and_output_schema() {
659        let tools = RssServer::tool_router().list_all();
660
661        let fetch = tools
662            .iter()
663            .find(|t| t.name == "fetch_feed")
664            .expect("fetch_feed tool registered");
665        let ann = fetch.annotations.as_ref().expect("fetch_feed annotations");
666        assert_eq!(ann.read_only_hint, Some(true));
667        assert_eq!(ann.idempotent_hint, Some(true));
668        assert_eq!(ann.open_world_hint, Some(true), "fetch hits the network");
669        assert!(
670            fetch.output_schema.is_some(),
671            "fetch_feed should advertise its FetchOutput output_schema"
672        );
673
674        // get_schema is local-only (not open-world) and has no fixed output shape.
675        let get_schema = tools
676            .iter()
677            .find(|t| t.name == "get_schema")
678            .expect("get_schema tool registered");
679        assert_eq!(
680            get_schema
681                .annotations
682                .as_ref()
683                .and_then(|a| a.open_world_hint),
684            Some(false),
685            "get_schema does not touch the network"
686        );
687        assert!(get_schema.output_schema.is_none());
688    }
689
690    #[tokio::test]
691    async fn fetch_feed_success_uses_structured_content_with_summary_text() {
692        let mut server = mockito::Server::new_async().await;
693        let _m = server
694            .mock("GET", "/feed.xml")
695            .with_status(200)
696            .with_body(feed_with_items(3))
697            .create_async()
698            .await;
699        let (cache, dir) = temp_cache("structured");
700
701        let result = fetch_feed_inner(
702            &test_http(),
703            &cache,
704            fetch_args(format!("{}/feed.xml", server.url())),
705        )
706        .await;
707        let wire = serde_json::to_value(&result).expect("serialize result");
708
709        // The typed FetchOutput lives in structuredContent (matching the tool output_schema).
710        assert!(
711            wire.get("structuredContent").is_some_and(|v| !v.is_null()),
712            "success result should carry structuredContent: {wire}"
713        );
714        assert_eq!(wire["structuredContent"]["total_items"], 3);
715
716        // The unstructured content is only a short summary, NOT a second full copy of the
717        // payload (that would double the response and undercut the token budget).
718        let summary = summary_text(&result).expect("a text summary");
719        assert!(
720            summary.contains("item(s)"),
721            "summary should be terse: {summary}"
722        );
723        assert!(
724            !summary.contains("\"feeds\""),
725            "text content must not duplicate the full FetchOutput JSON: {summary}"
726        );
727
728        std::fs::remove_dir_all(&dir).ok();
729    }
730
731    #[tokio::test]
732    async fn error_result_has_no_structured_content() {
733        // An over-budget result is an error: it must stay text-only (the ErrorObj), with no
734        // structuredContent, so it never violates the success output_schema (ADR-0013).
735        let mut server = mockito::Server::new_async().await;
736        let _m = server
737            .mock("GET", "/feed.xml")
738            .with_status(200)
739            .with_body(feed_with_items(10))
740            .create_async()
741            .await;
742        let (cache, dir) = temp_cache("err-nostruct");
743
744        let mut args = fetch_args(format!("{}/feed.xml", server.url()));
745        args.max_response_tokens = Some(1);
746        let result = fetch_feed_inner(&test_http(), &cache, args).await;
747        let wire = serde_json::to_value(&result).expect("serialize result");
748
749        assert_eq!(wire["isError"], true);
750        assert!(
751            wire.get("structuredContent").is_none_or(|v| v.is_null()),
752            "error results must not carry structuredContent: {wire}"
753        );
754
755        std::fs::remove_dir_all(&dir).ok();
756    }
757
758    #[tokio::test]
759    async fn fetch_feed_over_budget_returns_structured_response_too_large() {
760        let mut server = mockito::Server::new_async().await;
761        let _m = server
762            .mock("GET", "/feed.xml")
763            .with_status(200)
764            .with_body(feed_with_items(10))
765            .create_async()
766            .await;
767        let (cache, dir) = temp_cache("budget");
768
769        let mut args = fetch_args(format!("{}/feed.xml", server.url()));
770        args.max_response_tokens = Some(1); // force overflow
771
772        let result = fetch_feed_inner(&test_http(), &cache, args).await;
773        let (is_error, payload) = decode(&result);
774
775        assert!(is_error, "an over-budget result must be an error");
776        // The error payload is a structured ErrorObj the agent can parse to self-recover.
777        assert_eq!(payload["code"], "RESPONSE_TOO_LARGE");
778        assert!(
779            payload["details"]["suggested_max_content_chars"]
780                .as_u64()
781                .is_some_and(|n| n >= 200),
782            "must suggest a max_content_chars to retry with: {payload}"
783        );
784        assert!(
785            payload["details"]["suggested_limit"].as_u64().is_some(),
786            "must suggest a limit to retry with: {payload}"
787        );
788
789        std::fs::remove_dir_all(&dir).ok();
790    }
791
792    #[tokio::test]
793    async fn fetch_feed_content_cap_truncates_and_marks() {
794        let long = "word ".repeat(200); // ~1000 chars
795        let feed = format!(
796            "<?xml version=\"1.0\"?><rss version=\"2.0\"><channel><title>Feed</title>\
797             <link>https://example.com/</link><item><title>Big</title>\
798             <link>https://example.com/big</link><description><![CDATA[<p>{long}</p>]]></description>\
799             </item></channel></rss>"
800        );
801        let mut server = mockito::Server::new_async().await;
802        let _m = server
803            .mock("GET", "/feed.xml")
804            .with_status(200)
805            .with_body(feed)
806            .create_async()
807            .await;
808        let (cache, dir) = temp_cache("trunc");
809
810        let mut args = fetch_args(format!("{}/feed.xml", server.url()));
811        args.max_content_chars = Some(15);
812
813        let (is_error, payload) = decode(&fetch_feed_inner(&test_http(), &cache, args).await);
814        assert!(
815            !is_error,
816            "truncated-but-fitting result should succeed: {payload}"
817        );
818        assert_eq!(payload["feeds"][0]["items"][0]["content_truncated"], true);
819        assert_eq!(
820            payload["truncation"]["items_content_truncated"].as_u64(),
821            Some(1)
822        );
823        assert_eq!(
824            payload["truncation"]["applied_limit"].as_u64(),
825            Some(MCP_DEFAULT_LIMIT as u64)
826        );
827
828        std::fs::remove_dir_all(&dir).ok();
829    }
830
831    #[tokio::test]
832    async fn fetch_feed_rejects_bad_content_format() {
833        let (cache, dir) = temp_cache("badfmt");
834        let mut args = fetch_args("https://example.com/feed.xml".to_string());
835        args.content_format = Some("yaml".to_string());
836
837        let (is_error, payload) = decode(&fetch_feed_inner(&test_http(), &cache, args).await);
838        assert!(is_error);
839        assert_eq!(payload["code"], "USAGE_ERROR");
840
841        std::fs::remove_dir_all(&dir).ok();
842    }
843
844    #[tokio::test]
845    async fn get_item_missing_id_is_structured_not_found() {
846        let mut server = mockito::Server::new_async().await;
847        let _m = server
848            .mock("GET", "/feed.xml")
849            .with_status(200)
850            .with_body(feed_with_items(3))
851            .create_async()
852            .await;
853        let (cache, dir) = temp_cache("getitem");
854
855        let args = GetItemArgs {
856            feed_url: format!("{}/feed.xml", server.url()),
857            id: "0000000000000000".to_string(),
858            max_content_chars: None,
859        };
860        let (is_error, payload) = decode(&get_item_inner(&cache, args).await);
861        assert!(is_error);
862        assert_eq!(payload["code"], "NOT_FOUND");
863
864        std::fs::remove_dir_all(&dir).ok();
865    }
866
867    #[test]
868    fn fetch_args_coerce_stringified_integers() {
869        // Many MCP clients serialize numeric tool arguments as JSON strings; the tool must
870        // accept "25" exactly as it accepts 25 (see `de_lenient_opt_usize`). Before this fix
871        // every such call failed with `invalid type: string "25", expected usize`.
872        let args: FetchFeedArgs = serde_json::from_str(
873            r#"{"url":"https://e.com/f","limit":"25","max_content_chars":"500","max_response_tokens":"8000"}"#,
874        )
875        .expect("stringified integers should deserialize");
876        assert_eq!(args.limit, Some(25));
877        assert_eq!(args.max_content_chars, Some(500));
878        assert_eq!(args.max_response_tokens, Some(8000));
879    }
880
881    #[test]
882    fn fetch_args_accept_native_integers() {
883        let args: FetchFeedArgs =
884            serde_json::from_str(r#"{"url":"https://e.com/f","limit":25,"max_content_chars":500}"#)
885                .expect("native integers should still deserialize");
886        assert_eq!(args.limit, Some(25));
887        assert_eq!(args.max_content_chars, Some(500));
888        assert_eq!(args.max_response_tokens, None);
889    }
890
891    #[test]
892    fn fetch_args_absent_null_and_empty_string_are_none() {
893        let absent: FetchFeedArgs =
894            serde_json::from_str(r#"{"url":"https://e.com/f"}"#).expect("absent fields ok");
895        assert_eq!(absent.limit, None);
896        assert_eq!(absent.max_content_chars, None);
897
898        let null: FetchFeedArgs = serde_json::from_str(
899            r#"{"url":"https://e.com/f","limit":null,"max_content_chars":null}"#,
900        )
901        .expect("explicit null ok");
902        assert_eq!(null.limit, None);
903        assert_eq!(null.max_content_chars, None);
904
905        // An empty string is treated as "unset" rather than a parse error — some clients send
906        // "" for a cleared optional field.
907        let empty: FetchFeedArgs = serde_json::from_str(r#"{"url":"https://e.com/f","limit":""}"#)
908            .expect("empty string ok");
909        assert_eq!(empty.limit, None);
910    }
911
912    #[test]
913    fn fetch_args_reject_non_numeric_and_negative() {
914        assert!(
915            serde_json::from_str::<FetchFeedArgs>(r#"{"url":"u","limit":"twenty"}"#).is_err(),
916            "a non-numeric string must not silently parse"
917        );
918        assert!(
919            serde_json::from_str::<FetchFeedArgs>(r#"{"url":"u","limit":-5}"#).is_err(),
920            "a negative number is not a valid usize"
921        );
922        assert!(
923            serde_json::from_str::<FetchFeedArgs>(r#"{"url":"u","limit":"-5"}"#).is_err(),
924            "a negative numeric string is not a valid usize"
925        );
926    }
927
928    #[test]
929    fn get_item_args_coerce_stringified_max_content_chars() {
930        let args: GetItemArgs = serde_json::from_str(
931            r#"{"feed_url":"https://e.com/f","id":"abc","max_content_chars":"1000"}"#,
932        )
933        .expect("stringified max_content_chars should deserialize");
934        assert_eq!(args.max_content_chars, Some(1000));
935    }
936}