#![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;
#[derive(Debug, Deserialize, JsonSchema)]
struct RssFetchRequest {
action: String,
#[serde(default)]
url: Option<String>,
#[serde(default)]
id: Option<u32>,
#[serde(default)]
limit: Option<usize>,
}
#[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(),
),
}
}
}
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)]))
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let service = Server::new()?.serve(rmcp::transport::stdio()).await?;
service.waiting().await?;
Ok(())
}