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