Skip to main content
Glama

MCP RSS

by ronnycoding
index.ts•19.4 kB
#!/usr/bin/env node import 'reflect-metadata'; import dotenv from 'dotenv'; import cron from 'node-cron'; import { initDatabase } from './config/database'; import { OpmlService } from './services/OpmlService'; import { RssService } from './services/RssService'; import { McpService } from './services/McpService'; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { ArticleStatus } from './entities/Article'; const server = new Server( { name: "rss_mcp", version: "2.0.0", }, { capabilities: { tools: {}, }, }, ); // Load environment variables dotenv.config(); // Get environment variables const opmlFilePath = process.env.OPML_FILE_PATH || './feeds.opml'; const updateInterval = process.env.RSS_UPDATE_INTERVAL || '1'; // Initialize services const opmlService = new OpmlService(); const rssService = new RssService(); const mcpService = new McpService(); server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "get_content", description: "Browse articles with pagination and filtering. Returns latest articles first (by pubDate DESC). Use for: checking recent articles, browsing unread items, filtering by status/source/date. Token-efficient: returns ONLY titles + metadata by default. Add includeExcerpt for previews or includeContent for full text. Supports: multi-status filtering, date ranges, favorite blog prioritization. Response includes total count for pagination.", inputSchema: { type: "object", properties: { statuses: { type: "array", items: { type: "string", enum: ["unread", "read", "favorite", "archived"] }, description: "Filter by article statuses (can be multiple)" }, source: { type: "string", description: "Filter by feed source title" }, limit: { type: "number", description: "Number of articles to return (default: 10)", default: 10 }, offset: { type: "number", description: "Offset for pagination (default: 0)", default: 0 }, favoriteBlogsOnly: { type: "boolean", description: "Only show articles from favorite blogs (default: false)" }, prioritizeFavoriteBlogs: { type: "boolean", description: "Show favorite blog articles first, then others (default: false)" }, includeContent: { type: "boolean", description: "Include full article content (default: false, to save tokens). Use get_article_full for specific articles instead.", default: false }, includeExcerpt: { type: "boolean", description: "Include article excerpt/preview (default: false). Only use when you need content preview.", default: false }, startDate: { type: "string", description: "Start date for filtering (ISO format: YYYY-MM-DD or YYYY-MM-DDTHH:mm:ssZ)" }, endDate: { type: "string", description: "End date for filtering (ISO format: YYYY-MM-DD or YYYY-MM-DDTHH:mm:ssZ)" } }, }, }, { name: "search_articles", description: "Search articles by keyword (title + content), category, status, and date range. Case-insensitive full-text search. Use for: finding specific topics, complex multi-filter queries, category exploration. Returns latest first (by pubDate DESC). Includes excerpts by default. Set includeContent=true for full text. Supports pagination with limit/offset. Response includes total count.", inputSchema: { type: "object", properties: { keyword: { type: "string", description: "Search term for title/content (case-insensitive)" }, category: { type: "string", description: "Filter by feed category (e.g., 'Engineering', 'AI')" }, statuses: { type: "array", items: { type: "string", enum: ["unread", "read", "favorite", "archived"] }, description: "Filter by article statuses" }, startDate: { type: "string", description: "Start date for filtering (ISO format: YYYY-MM-DD or YYYY-MM-DDTHH:mm:ssZ)" }, endDate: { type: "string", description: "End date for filtering (ISO format)" }, limit: { type: "number", description: "Number of results (default: 20)", default: 20 }, offset: { type: "number", description: "Offset for pagination (default: 0)", default: 0 }, favoriteBlogsOnly: { type: "boolean", description: "Only show articles from favorite blogs (default: false)" }, prioritizeFavoriteBlogs: { type: "boolean", description: "Show favorite blog articles first, then others (default: false)" }, includeContent: { type: "boolean", description: "Include full article content (default: false, to save tokens). Use get_article_full for specific articles instead.", default: false } }, }, }, { name: "semantic_search", description: "AI-powered semantic search using vector embeddings (OpenAI text-embedding-3-small). Finds conceptually similar articles without exact keyword matches. Use for: natural language queries (e.g., 'kubernetes performance tips'), research exploration, finding related content. Returns articles sorted by similarity. Includes excerpts by default. REQUIRES: OPENAI_API_KEY environment variable. Works for articles from 2020+.", inputSchema: { type: "object", properties: { query: { type: "string", description: "Natural language search query (e.g., 'articles about kubernetes performance optimization')" }, limit: { type: "number", description: "Number of results (default: 10)", default: 10 }, statuses: { type: "array", items: { type: "string", enum: ["unread", "read", "favorite", "archived"] }, description: "Filter by article statuses" }, category: { type: "string", description: "Filter by feed category" }, includeContent: { type: "boolean", description: "Include full article content (default: false, to save tokens). Use get_article_full for specific articles instead.", default: false } }, required: ["query"] }, }, { name: "get_daily_digest", description: "Get today's unread articles grouped by category. Perfect for morning briefing or daily catch-up. Returns articles published today with status='unread', organized by category (up to N per category). Sorted by category name, then pubDate DESC within category. Includes excerpts by default. Set includeContent=true for full text.", inputSchema: { type: "object", properties: { limit: { type: "number", description: "Max articles per category (default: 5)", default: 5 }, includeContent: { type: "boolean", description: "Include full article content (default: false, to save tokens). Use get_article_full for specific articles instead.", default: false } }, }, }, { name: "get_weekly_favorites", description: "Get favorite articles from the last 7 days. Perfect for weekly review of bookmarked content. Returns articles with status='favorite' published in the past week, sorted by pubDate DESC. Ultra token-efficient: titles + metadata only (no excerpts or content). Use for weekly reading lists and reviewing important bookmarked articles.", inputSchema: { type: "object", properties: {}, } }, { name: "get_article_full", description: "Get full article content by ID. Use AFTER browsing titles with get_content/search_articles. Token-efficient workflow: (1) browse titles only, (2) identify interesting articles, (3) fetch full content for selected articles. Returns single article with complete text, metadata, and excerpt.", inputSchema: { type: "object", properties: { articleId: { type: "number", description: "Article ID to retrieve (get IDs from get_content or search_articles)" } }, required: ["articleId"] } }, { name: "get_sources", description: "List RSS feed sources with pagination and filtering. Get feed IDs, titles, categories, and URLs. Use to: discover available sources, find exact feed names for filtering, browse by category. Returns feeds sorted by favorite status (favorites first), then alphabetically. With 518+ feeds, pagination is REQUIRED (default: 50/page). Response includes total count. Call before using source filter in other tools.", inputSchema: { type: "object", properties: { limit: { type: "number", description: "Number of sources to return (default: 50, max recommended: 100 to avoid token limits)", default: 50 }, offset: { type: "number", description: "Offset for pagination (default: 0). Use offset=50 for page 2, offset=100 for page 3, etc.", default: 0 }, favoritesOnly: { type: "boolean", description: "Only show favorite blogs (default: false)" }, category: { type: "string", description: "Filter by category (case-insensitive partial match, e.g., 'Engineering', 'AI')" } }, }, }, { name: "set_tag", description: "Update article status for reading workflow management. Changes status of a single article by ID. Use to: mark as read after reading, bookmark favorites, archive irrelevant content. Status workflow: unread (default) → read (consumed) → favorite (bookmarked) → archived (hidden). Get article IDs from get_content/search_articles first.", inputSchema: { type: "object", properties: { articleId: { type: "number", description: "Article ID to update" }, status: { type: "string", enum: ["unread", "read", "favorite", "archived"], description: "New status for the article" } }, required: ["articleId", "status"] } }, { name: "set_favorite_blog", description: "Mark a blog/feed as favorite or remove from favorites. Favorite blogs appear first in get_sources and can be prioritized/filtered in get_content/search_articles. Use to: highlight preferred blogs, create reading priorities, filter by favorites. Get feed IDs from get_sources first. Workflow: (1) get_sources to find feed ID, (2) set_favorite_blog to mark it, (3) use favoriteBlogsOnly or prioritizeFavoriteBlogs filters.", inputSchema: { type: "object", properties: { feedId: { type: "number", description: "Blog/Feed ID to update (get from get_sources)" }, isFavorite: { type: "boolean", description: "True to mark as favorite, false to remove from favorites" } }, required: ["feedId", "isFavorite"] } } ], }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { switch (request.params.name) { case "get_content": { const statuses = request.params.arguments?.statuses as string[] | undefined; const source = request.params.arguments?.source as string | undefined; const limit = (request.params.arguments?.limit as number) || 10; const offset = (request.params.arguments?.offset as number) || 0; const favoriteBlogsOnly = request.params.arguments?.favoriteBlogsOnly as boolean | undefined; const prioritizeFavoriteBlogs = request.params.arguments?.prioritizeFavoriteBlogs as boolean | undefined; const includeContent = request.params.arguments?.includeContent as boolean | undefined; const includeExcerpt = request.params.arguments?.includeExcerpt as boolean | undefined; const startDate = request.params.arguments?.startDate as string | undefined; const endDate = request.params.arguments?.endDate as string | undefined; const result = await mcpService.get_content( statuses as ArticleStatus[] | undefined, limit, offset, source, favoriteBlogsOnly, prioritizeFavoriteBlogs, includeContent, includeExcerpt, startDate, endDate ); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], isError: false, }; } case "search_articles": { const keyword = request.params.arguments?.keyword as string | undefined; const category = request.params.arguments?.category as string | undefined; const statuses = request.params.arguments?.statuses as string[] | undefined; const startDate = request.params.arguments?.startDate as string | undefined; const endDate = request.params.arguments?.endDate as string | undefined; const limit = (request.params.arguments?.limit as number) || 20; const offset = (request.params.arguments?.offset as number) || 0; const favoriteBlogsOnly = request.params.arguments?.favoriteBlogsOnly as boolean | undefined; const prioritizeFavoriteBlogs = request.params.arguments?.prioritizeFavoriteBlogs as boolean | undefined; const includeContent = request.params.arguments?.includeContent as boolean | undefined; const result = await mcpService.search_articles( keyword, category, statuses as ArticleStatus[] | undefined, startDate, endDate, limit, offset, favoriteBlogsOnly, prioritizeFavoriteBlogs, includeContent ); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], isError: false, }; } case "semantic_search": { const query = request.params.arguments?.query as string; const limit = (request.params.arguments?.limit as number) || 10; const statuses = request.params.arguments?.statuses as string[] | undefined; const category = request.params.arguments?.category as string | undefined; const includeContent = request.params.arguments?.includeContent as boolean | undefined; const result = await mcpService.semantic_search( query, limit, statuses as ArticleStatus[] | undefined, category, includeContent ); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], isError: false, }; } case "get_daily_digest": { const limit = (request.params.arguments?.limit as number) || 5; const includeContent = request.params.arguments?.includeContent as boolean | undefined; const result = await mcpService.get_daily_digest(limit, includeContent); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], isError: false, }; } case "get_weekly_favorites": { const result = await mcpService.get_weekly_favorites(); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], isError: false, }; } case "get_article_full": { const articleId = request.params.arguments?.articleId as number; const result = await mcpService.get_article_full(articleId); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], isError: false, }; } case "get_sources": { const limit = (request.params.arguments?.limit as number) || 50; const offset = (request.params.arguments?.offset as number) || 0; const favoritesOnly = request.params.arguments?.favoritesOnly as boolean | undefined; const category = request.params.arguments?.category as string | undefined; const result = await mcpService.get_sources(limit, offset, favoritesOnly, category); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], isError: false, }; } case "set_tag": { const articleId = request.params.arguments?.articleId as number; const status = request.params.arguments?.status as string; const result = await mcpService.set_tag(articleId, status as ArticleStatus); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], isError: false, }; } case "set_favorite_blog": { const feedId = request.params.arguments?.feedId as number; const isFavorite = request.params.arguments?.isFavorite as boolean; const result = await mcpService.set_favorite_blog(feedId, isFavorite); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], isError: false, }; } default: throw new Error(`Unknown tool: ${request.params.name}`); } }); async function runServer() { await initDatabase(); // Setup cron job for periodic RSS updates const cronExpression = `*/${updateInterval} * * * *`; cron.schedule(cronExpression, async () => { // Parse OPML file and save feeds await opmlService.parseAndSaveFeeds(opmlFilePath); await rssService.fetchAllFeeds(); }); const transport = new StdioServerTransport(); await server.connect(transport); // Handle EPIPE errors gracefully (client disconnected) process.stdout.on('error', (err: NodeJS.ErrnoException) => { if (err.code === 'EPIPE') { process.exit(0); } }); process.stderr.on('error', (err: NodeJS.ErrnoException) => { if (err.code === 'EPIPE') { process.exit(0); } }); // Graceful shutdown const shutdown = () => { process.exit(0); }; process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); } runServer().catch(console.error);

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ronnycoding/my_mcp_rss'

If you have feedback or need assistance with the MCP directory API, please join our Discord server