rss-fetch 0.1.0

MCP server for fetching and reading RSS/Atom feeds
Documentation
// rmcp #[tool_router] macro generates collapsible-if patterns
#![allow(clippy::collapsible_if)]

use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::*;
use rmcp::{tool, tool_handler, tool_router, ServerHandler, ServiceExt};
use schemars::JsonSchema;
use serde::Deserialize;

use rss_fetch::EntryStore;

// ---------------------------------------------------------------------------
// Tool parameter types
// ---------------------------------------------------------------------------

#[derive(Debug, Deserialize, JsonSchema)]
struct RssFetchRequest {
    /// Action to perform: "list" to fetch feed, "get" to read an article.
    action: String,

    /// RSS/Atom feed URL (required for "list" action).
    #[serde(default)]
    url: Option<String>,

    /// Article ID from a previous "list" result (required for "get" action).
    #[serde(default)]
    id: Option<u32>,

    /// Maximum number of articles to return for "list" action (default: 20).
    #[serde(default)]
    limit: Option<usize>,
}

// ---------------------------------------------------------------------------
// MCP server
// ---------------------------------------------------------------------------

#[derive(Clone)]
struct Server {
    store: EntryStore,
    client: reqwest::Client,
    tool_router: ToolRouter<Self>,
}

fn mcp_error(msg: impl Into<String>) -> rmcp::ErrorData {
    rmcp::ErrorData::internal_error(msg.into(), None)
}

#[tool_router]
impl Server {
    fn new() -> anyhow::Result<Self> {
        Ok(Self {
            store: EntryStore::new(),
            client: rss_fetch::build_http_client()?,
            tool_router: Self::tool_router(),
        })
    }

    #[tool(description = "Fetch RSS/Atom feeds and read articles. \
        action=\"list\": pass url to get article list as markdown table (default: 20 articles, use limit to change). \
        action=\"get\": pass id (number from list) to read full article text.")]
    async fn rss_fetch(
        &self,
        Parameters(req): Parameters<RssFetchRequest>,
    ) -> Result<CallToolResult, rmcp::ErrorData> {
        match req.action.as_str() {
            "list" => self.handle_list(req.url, req.limit).await,
            "get" => self.handle_get(req.id).await,
            other => Ok(CallToolResult::error(vec![Content::text(format!(
                "Unknown action: \"{other}\". Use \"list\" or \"get\"."
            ))])),
        }
    }
}

#[tool_handler]
impl ServerHandler for Server {
    fn get_info(&self) -> ServerInfo {
        ServerInfo {
            protocol_version: ProtocolVersion::V_2024_11_05,
            capabilities: ServerCapabilities::builder().enable_tools().build(),
            server_info: Implementation {
                name: "rss-fetch-mcp".into(),
                version: env!("CARGO_PKG_VERSION").into(),
                title: None,
                description: None,
                icons: None,
                website_url: None,
            },
            instructions: Some(
                "RSS/Atom feed reader. Use action=\"list\" with a feed URL to get articles, \
                 then action=\"get\" with an article # to read it."
                    .into(),
            ),
        }
    }
}

// ---------------------------------------------------------------------------
// Handler logic
// ---------------------------------------------------------------------------

impl Server {
    async fn handle_list(
        &self,
        url: Option<String>,
        limit: Option<usize>,
    ) -> Result<CallToolResult, rmcp::ErrorData> {
        let url = match url {
            Some(u) if !u.is_empty() => u,
            _ => {
                return Ok(CallToolResult::error(vec![Content::text(
                    "\"url\" parameter is required for action=\"list\".",
                )]));
            }
        };

        let limit = limit.unwrap_or(rss_fetch::DEFAULT_LIST_LIMIT);

        let (feed_title, parsed) = rss_fetch::fetch_and_parse_feed(&self.client, &url)
            .await
            .map_err(|e| mcp_error(format!("Failed to fetch/parse feed: {e}")))?;

        let total = parsed.len();
        let truncated = parsed.into_iter().take(limit).collect::<Vec<_>>();

        let entries = self
            .store
            .store_entries(truncated)
            .await
            .map_err(|e| mcp_error(format!("Failed to store entries: {e}")))?;
        let md = rss_fetch::format_entries_as_markdown(&feed_title, &entries, total);

        Ok(CallToolResult::success(vec![Content::text(md)]))
    }

    async fn handle_get(&self, id: Option<u32>) -> Result<CallToolResult, rmcp::ErrorData> {
        let id = match id {
            Some(i) if i > 0 => i,
            _ => {
                return Ok(CallToolResult::error(vec![Content::text(
                    "\"id\" parameter (positive number) is required for action=\"get\".",
                )]));
            }
        };

        let (url, title) = match self.store.get_url(id).await {
            Some(pair) => pair,
            None => {
                return Ok(CallToolResult::error(vec![Content::text(format!(
                    "No article found for id={id}. Run \"list\" first."
                ))]));
            }
        };

        let text = rss_fetch::fetch_article_text(&self.client, &url)
            .await
            .map_err(|e| mcp_error(format!("Failed to fetch article: {e}")))?;

        let result = format!("# {title}\nSource: {url}\n\n{text}");
        Ok(CallToolResult::success(vec![Content::text(result)]))
    }
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let service = Server::new()?.serve(rmcp::transport::stdio()).await?;
    service.waiting().await?;
    Ok(())
}