1use 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
33const 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
42const MCP_DEFAULT_LIMIT: usize = 25;
45
46const MCP_DEFAULT_MAX_RESPONSE_TOKENS: usize = 10_000;
57
58fn 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#[derive(Debug, Deserialize, JsonSchema)]
112struct FetchFeedArgs {
113 url: String,
115 #[serde(default)]
117 content_format: Option<String>,
118 #[serde(default, deserialize_with = "de_lenient_opt_usize")]
121 limit: Option<usize>,
122 #[serde(default, deserialize_with = "de_lenient_opt_usize")]
125 max_content_chars: Option<usize>,
126 #[serde(default, deserialize_with = "de_lenient_opt_usize")]
130 max_response_tokens: Option<usize>,
131}
132
133#[derive(Debug, Deserialize, JsonSchema)]
135struct DiscoverFeedsArgs {
136 site_url: String,
138}
139
140#[derive(Debug, Deserialize, JsonSchema)]
142struct GetItemArgs {
143 feed_url: String,
145 id: String,
150 #[serde(default, deserialize_with = "de_lenient_opt_usize")]
153 max_content_chars: Option<usize>,
154}
155
156#[derive(Debug, Deserialize, JsonSchema)]
158struct GetSchemaArgs {
159 command: String,
161}
162
163#[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 #[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 #[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 #[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 #[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
291async 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 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), ¶ms, cache, http).await;
314
315 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 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
342fn 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
372async 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, ¶ms, 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
414fn 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
429fn 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
450fn tool_error_obj(err: &RssError, feed_url: Option<&str>) -> CallToolResult {
454 error_result(err.to_error_obj(feed_url))
455}
456
457fn 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
471fn 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
482fn 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
493pub async fn serve_stdio(cache: Cache) -> Result<(), RssError> {
495 let params = FetchParams::default();
497 let http = HttpClient::new(¶ms.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 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 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 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 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 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 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 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 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 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); 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 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); 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 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 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}