1use 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
32const 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
41const MCP_DEFAULT_LIMIT: usize = 25;
44
45const MCP_DEFAULT_MAX_RESPONSE_TOKENS: usize = 10_000;
56
57fn 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#[derive(Debug, Deserialize, JsonSchema)]
111struct FetchFeedArgs {
112 url: String,
114 #[serde(default)]
116 content_format: Option<String>,
117 #[serde(default, deserialize_with = "de_lenient_opt_usize")]
120 limit: Option<usize>,
121 #[serde(default, deserialize_with = "de_lenient_opt_usize")]
124 max_content_chars: Option<usize>,
125 #[serde(default, deserialize_with = "de_lenient_opt_usize")]
129 max_response_tokens: Option<usize>,
130}
131
132#[derive(Debug, Deserialize, JsonSchema)]
134struct DiscoverFeedsArgs {
135 site_url: String,
137}
138
139#[derive(Debug, Deserialize, JsonSchema)]
141struct GetItemArgs {
142 feed_url: String,
144 id: String,
149 #[serde(default, deserialize_with = "de_lenient_opt_usize")]
152 max_content_chars: Option<usize>,
153}
154
155#[derive(Debug, Deserialize, JsonSchema)]
157struct GetSchemaArgs {
158 command: String,
160}
161
162#[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 #[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 #[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 #[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 #[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
286async 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 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), ¶ms, cache).await;
308
309 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 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
336fn 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
366async 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, ¶ms, 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
408fn 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
423fn 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
444fn tool_error_obj(err: &RssError, feed_url: Option<&str>) -> CallToolResult {
448 error_result(err.to_error_obj(feed_url))
449}
450
451fn 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
465fn 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
476fn 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
487pub 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 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 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 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 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 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 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 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); 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 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); 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 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 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}