Headline Vibes Analysis MCP Server

#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, CallToolRequest, } from '@modelcontextprotocol/sdk/types.js'; import axios from 'axios'; import Sentiment from 'sentiment'; import * as chrono from 'chrono-node'; const NEWS_API_KEY = process.env.NEWS_API_KEY; if (!NEWS_API_KEY) { throw new Error('NEWS_API_KEY environment variable is required'); } interface Article { title: string; publishedAt: string; source: { id: string; name: string; }; } interface NewsAPIResponse { articles: Article[]; totalResults: number; } // Major US news sources for better coverage const PREFERRED_SOURCES = [ 'associated-press', 'reuters', 'cnn', 'fox-news', 'nbc-news', 'abc-news', 'the-wall-street-journal', 'the-washington-post', 'usa-today', 'bloomberg', 'business-insider', 'time' ].join(','); interface SentimentResult { score: number; comparative: number; } class HeadlineSentimentServer { private server: Server; private sentiment: Sentiment; private axiosInstance: ReturnType<typeof axios.create>; constructor() { this.server = new Server( { name: 'headline-vibes', version: '0.1.0', }, { capabilities: { tools: {}, }, } ); this.sentiment = new Sentiment(); this.axiosInstance = axios.create({ baseURL: 'https://newsapi.org/v2', headers: { 'X-Api-Key': NEWS_API_KEY, }, }); this.setupToolHandlers(); // Error handling this.server.onerror = (error: Error) => console.error('[MCP Error]', error); process.on('SIGINT', async () => { await this.server.close(); process.exit(0); }); } private normalizeScore(rawScore: number): number { // Normalize the raw sentiment score to a 0-10 scale // Assuming typical raw scores range from -5 to +5 const normalized = (rawScore + 5) * (10 / 10); return Math.max(0, Math.min(10, normalized)); } private getSentimentSynopsis(normalizedScore: number, headlines: string[]): string { // Categorize headlines by sentiment impact const sentiments = headlines.map(headline => ({ text: headline, score: this.sentiment.analyze(headline).comparative })); const strongPositive = sentiments.filter(s => s.score > 0.5); const moderatePositive = sentiments.filter(s => s.score > 0.2 && s.score <= 0.5); const strongNegative = sentiments.filter(s => s.score < -0.5); const moderateNegative = sentiments.filter(s => s.score < -0.2 && s.score >= -0.5); let marketImpact = ""; if (normalizedScore < 3) { marketImpact = "Market sentiment appears bearish, with significant negative coverage potentially impacting investor confidence."; } else if (normalizedScore < 4.5) { marketImpact = "Market sentiment leans cautious, with mixed but predominantly negative signals."; } else if (normalizedScore < 5.5) { marketImpact = "Market sentiment is balanced, with no strong directional bias in the coverage."; } else if (normalizedScore < 7) { marketImpact = "Market sentiment leans optimistic, with positive developments outweighing concerns."; } else { marketImpact = "Market sentiment appears bullish, with strong positive coverage likely boosting investor confidence."; } // Add context about the distribution of sentiment const sentimentDistribution = [ `${strongPositive.length} headlines (${Math.round((strongPositive.length / headlines.length) * 100)}%) show strongly positive sentiment`, `${moderatePositive.length} headlines (${Math.round((moderatePositive.length / headlines.length) * 100)}%) show moderately positive sentiment`, `${moderateNegative.length} headlines (${Math.round((moderateNegative.length / headlines.length) * 100)}%) show moderately negative sentiment`, `${strongNegative.length} headlines (${Math.round((strongNegative.length / headlines.length) * 100)}%) show strongly negative sentiment` ].join(', '); return `Sentiment Score: ${normalizedScore.toFixed(2)} out of 10\n\n${marketImpact}\n\nSentiment Distribution: ${sentimentDistribution}`; } private parseDate(query: string): string { const now = new Date(); const parsedDate = chrono.parseDate(query, now, { forwardDate: false }); if (!parsedDate) { throw new McpError( ErrorCode.InvalidParams, 'Could not understand the date in your query. Please try something like "yesterday", "last Friday", or a specific date.' ); } return parsedDate.toISOString().split('T')[0]; } private async analyzeHeadlinesForDate(date: string) { try { // Fetch headlines in batches using pagination (max pageSize is 100) let articles: Article[] = []; const pageSize = 100; let page = 1; const maxHeadlines = 250; while (articles.length < maxHeadlines) { const response = await this.axiosInstance.get<NewsAPIResponse>('/top-headlines', { params: { sources: PREFERRED_SOURCES, from: date, to: date, language: 'en', pageSize: pageSize, page: page, }, }); if (response.data.articles.length === 0) { break; } articles = articles.concat(response.data.articles); if (response.data.articles.length < pageSize) { break; } page++; } // Limit to maxHeadlines if necessary if (articles.length > maxHeadlines) { articles = articles.slice(0, maxHeadlines); } // Track original source distribution before any filtering const sourceDistribution: { [key: string]: number } = {}; articles.forEach(article => { const sourceName = article.source.name || 'Unknown'; sourceDistribution[sourceName] = (sourceDistribution[sourceName] || 0) + 1; }); // Group headlines by source const headlinesBySource = articles.reduce((acc: { [key: string]: string[] }, article) => { const sourceName = article.source.name || 'Unknown'; if (!acc[sourceName]) { acc[sourceName] = []; } acc[sourceName].push(article.title); return acc; }, {}); // Get an even distribution of headlines from each source const headlines: string[] = []; const sources = Object.keys(headlinesBySource); const maxPerSource = Math.ceil(articles.length / sources.length); // Ensure even distribution sources.forEach(source => { const sourceHeadlines = headlinesBySource[source]; const count = Math.min(sourceHeadlines.length, maxPerSource); headlines.push(...sourceHeadlines.slice(0, count)); }); // Trim to maxHeadlines if we exceeded that if (headlines.length > maxHeadlines) { headlines.length = maxHeadlines; } if (headlines.length === 0) { return { content: [ { type: 'text', text: 'No headlines found for the specified date.', }, ], }; } // Analyze sentiment for each headline const sentimentScores = headlines.map((headline: string) => this.sentiment.analyze(headline).comparative ); // Calculate average sentiment const averageScore = sentimentScores.reduce((a: number, b: number) => a + b, 0) / sentimentScores.length; // Normalize to 0-10 scale const normalizedScore = this.normalizeScore(averageScore); // Generate synopsis with full headlines context const synopsis = this.getSentimentSynopsis(normalizedScore, headlines); // Format the response with additional source information return { score: normalizedScore.toFixed(2), synopsis, headlines_analyzed: headlines.length, sources_analyzed: sources.length, source_distribution: sourceDistribution, sample_headlines: headlines.slice(0, 10) // Increased sample size }; } catch (error: any) { const message = error instanceof Error ? error.message : 'Unknown error'; throw new McpError( ErrorCode.InternalError, `NewsAPI error: ${message}` ); } } private setupToolHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'analyze_headlines', description: 'Analyze sentiment of major news headlines using natural language date input', inputSchema: { type: 'object', properties: { input: { type: 'string', description: 'Date input (e.g., "yesterday", "last Friday", "March 10th", or "2025-02-11")', }, }, required: ['input'], }, }, ], })); this.server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => { if (request.params.name !== 'analyze_headlines') { throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}` ); } const { input } = request.params.arguments as { input: string }; if (!input) { throw new McpError( ErrorCode.InvalidParams, 'Please provide a date input (e.g., "yesterday", "last Friday")' ); } // First try to parse as exact date format let date: string; if (input.match(/^\d{4}-\d{2}-\d{2}$/)) { date = input; } else { // If not an exact date format, use NLP parsing date = this.parseDate(input); } const result = await this.analyzeHeadlinesForDate(date); return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], }; }); } async run() { try { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('Headline Sentiment MCP server running on stdio'); } catch (error: any) { console.error('Failed to start server:', error); process.exit(1); } } } const server = new HeadlineSentimentServer(); server.run().catch(error => { console.error('Critical server error:', error); process.exit(1); });