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);