topic-scout-mcp
Version:
MCP Server para buscar notícias e identificar tendências sobre tópicos específicos
378 lines (316 loc) • 11.1 kB
text/typescript
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
interface NewsArticle {
source: {
id: string | null;
name: string;
};
author: string | null;
title: string;
description: string | null;
url: string;
urlToImage: string | null;
publishedAt: string;
content: string | null;
}
interface NewsSearchResult {
status: string;
totalResults: number;
articles: NewsArticle[];
}
export class TopicScoutMCPServer {
private server: Server;
private newsApiKey: string;
private serverName: string;
constructor(serverName?: string) {
this.serverName = serverName || "topic-scout-mcp";
this.server = new Server({
name: this.serverName,
version: "1.0.0",
});
this.newsApiKey = process.env.NEWS_API_KEY || "";
if (!this.newsApiKey) {
console.error("⚠️ NEWS_API_KEY não encontrada no ambiente");
}
this.setupToolHandlers();
}
private setupToolHandlers() {
// Listar ferramentas disponíveis
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "search_news_articles",
description: "Busca artigos de notícias sobre um tópico específico",
inputSchema: {
type: "object",
properties: {
topic: {
type: "string",
description: "O tópico principal para buscar notícias (ex: 'Real Estate', 'Technology', 'Sports')",
},
timeframe: {
type: "string",
description: "Período de busca: '7d', '30d', '90d' (padrão: '7d')",
default: "7d",
},
max_articles: {
type: "number",
description: "Número máximo de artigos para buscar (padrão: 50)",
default: 50,
},
language: {
type: "string",
description: "Idioma das notícias: 'en', 'pt' (padrão: 'en')",
default: "en",
},
},
required: ["topic"],
},
},
{
name: "get_news_sources",
description: "Lista as principais fontes de notícias disponíveis",
inputSchema: {
type: "object",
properties: {
category: {
type: "string",
description: "Categoria de fontes (ex: 'business', 'technology', 'general')",
},
language: {
type: "string",
description: "Idioma das fontes (padrão: 'en')",
default: "en",
},
},
},
},
],
};
});
// Executar ferramentas
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === "search_news_articles") {
return await this.searchNewsArticles(args);
}
if (name === "get_news_sources") {
return await this.getNewsSources(args);
}
throw new Error(`Ferramenta desconhecida: ${name}`);
});
}
private async searchNewsArticles(args: any): Promise<any> {
try {
const { topic, timeframe = "7d", max_articles = 50, language = "en" } = args;
if (!topic) {
return {
content: [
{
type: "text",
text: "❌ Erro: O parâmetro 'topic' é obrigatório",
},
],
};
}
console.error(`🔍 Buscando notícias sobre: ${topic}`);
// Calcular data de início baseada no timeframe
const startDate = this.calculateStartDate(timeframe);
// Buscar notícias sobre o tópico
const news = await this.searchNews(topic, startDate, max_articles, language);
if (!news.articles || news.articles.length === 0) {
return {
content: [
{
type: "text",
text: `❌ Nenhuma notícia encontrada sobre "${topic}" nos últimos ${timeframe}. Tente um tópico diferente ou verifique a ortografia.`,
},
],
};
}
// Formatar resposta com dados brutos para a AI analisar
const response = this.formatNewsResponse(topic, news, timeframe);
return {
content: [
{
type: "text",
text: response,
},
],
};
} catch (error) {
console.error("❌ Erro na busca:", error);
return {
content: [
{
type: "text",
text: `❌ Erro ao buscar notícias: ${error instanceof Error ? error.message : "Erro desconhecido"}`,
},
],
};
}
}
private async getNewsSources(args: any): Promise<any> {
try {
const { category, language = "en" } = args;
console.error(`📰 Buscando fontes de notícias...`);
const sources = await this.fetchNewsSources(category, language);
if (!sources.sources || sources.sources.length === 0) {
return {
content: [
{
type: "text",
text: "❌ Nenhuma fonte de notícias encontrada.",
},
],
};
}
const response = this.formatSourcesResponse(sources, category);
return {
content: [
{
type: "text",
text: response,
},
],
};
} catch (error) {
console.error("❌ Erro ao buscar fontes:", error);
return {
content: [
{
type: "text",
text: `❌ Erro ao buscar fontes: ${error instanceof Error ? error.message : "Erro desconhecido"}`,
},
],
};
}
}
private calculateStartDate(timeframe: string): string {
const now = new Date();
let daysAgo: number;
switch (timeframe) {
case "7d":
daysAgo = 7;
break;
case "30d":
daysAgo = 30;
break;
case "90d":
daysAgo = 90;
break;
default:
daysAgo = 7;
}
const startDate = new Date(now.getTime() - (daysAgo * 24 * 60 * 60 * 1000));
return startDate.toISOString().split('T')[0]; // Formato YYYY-MM-DD
}
private async searchNews(query: string, startDate: string, maxResults: number, language: string): Promise<NewsSearchResult> {
const url = `https://newsapi.org/v2/everything`;
const params = new URLSearchParams({
q: query,
from: startDate,
sortBy: "publishedAt",
pageSize: maxResults.toString(),
language: language,
apiKey: this.newsApiKey,
});
const response = await fetch(`${url}?${params}`, {
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`Erro na API de Notícias: ${response.status} ${response.statusText}`);
}
return await response.json();
}
private async fetchNewsSources(category?: string, language: string = "en"): Promise<any> {
const url = `https://newsapi.org/v2/sources`;
const params = new URLSearchParams({
language: language,
apiKey: this.newsApiKey,
});
if (category) {
params.append("category", category);
}
const response = await fetch(`${url}?${params}`, {
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`Erro na API de Fontes: ${response.status} ${response.statusText}`);
}
return await response.json();
}
private formatNewsResponse(topic: string, news: NewsSearchResult, timeframe: string): string {
let response = `📰 **NOTÍCIAS SOBRE "${topic.toUpperCase()}"** 📰\n\n`;
response += `📊 Encontrados ${news.totalResults} artigos nos últimos ${timeframe}\n`;
response += `📋 Analisando ${news.articles.length} artigos mais relevantes\n\n`;
response += `📑 **ARTIGOS ENCONTRADOS:**\n\n`;
for (let i = 0; i < Math.min(10, news.articles.length); i++) {
const article = news.articles[i];
const date = new Date(article.publishedAt).toLocaleDateString('pt-BR');
response += `${i + 1}. **${article.title}**\n`;
response += ` 📰 Fonte: ${article.source.name}\n`;
response += ` 📅 Data: ${date}\n`;
if (article.description) {
response += ` 📝 Resumo: ${article.description.substring(0, 150)}...\n`;
}
response += ` 🔗 URL: ${article.url}\n\n`;
}
response += `💡 **DADOS PARA ANÁLISE:**\n`;
response += `• Total de artigos: ${news.articles.length}\n`;
response += `• Período analisado: ${timeframe}\n`;
response += `• Fontes principais: ${this.getTopSources(news.articles)}\n\n`;
response += `🔍 **PRÓXIMOS PASSOS:**\n`;
response += `• Analise os títulos e resumos para identificar temas recorrentes\n`;
response += `• Identifique as fontes mais ativas sobre o tópico\n`;
response += `• Compare com outros tópicos relacionados se necessário\n`;
return response;
}
private formatSourcesResponse(sources: any, category?: string): string {
let response = `📰 **FONTES DE NOTÍCIAS DISPONÍVEIS** 📰\n\n`;
if (category) {
response += `📂 Categoria: ${category}\n`;
}
response += `📊 Total de fontes: ${sources.sources.length}\n\n`;
response += `🏢 **PRINCIPAIS FONTES:**\n\n`;
for (let i = 0; i < Math.min(15, sources.sources.length); i++) {
const source = sources.sources[i];
response += `${i + 1}. **${source.name}**\n`;
response += ` 🌐 País: ${source.country}\n`;
response += ` 📂 Categoria: ${source.category}\n`;
response += ` 🔗 URL: ${source.url}\n\n`;
}
return response;
}
private getTopSources(articles: NewsArticle[]): string {
const sourceCount = new Map<string, number>();
for (const article of articles) {
const sourceName = article.source.name;
sourceCount.set(sourceName, (sourceCount.get(sourceName) || 0) + 1);
}
const sortedSources = Array.from(sourceCount.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([name, count]) => `${name} (${count})`);
return sortedSources.join(", ");
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error(`🚀 ${this.serverName} MCP Server iniciado`);
}
}
// Executar o servidor apenas se for chamado diretamente
if (import.meta.url === `file://${process.argv[1]}`) {
const server = new TopicScoutMCPServer();
server.run().catch(console.error);
}