fetcthNews.ts•5.48 kB
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
export function registerSearchNewsRss(server: McpServer) {
server.tool(
"search-news-rss",
"Search multiple public RSS feeds (no API key) for headlines matching a query",
{
q: z.string().describe("Query to search in headlines/description"),
limit: z
.string()
.optional()
.describe("Max results to return (default 10)"),
sources: z
.array(z.string())
.optional()
.describe(
"Optional list of source keys to query. Defaults to all. Keys: 'bbcmundo','elpais','reuters','cnn','ap','euronews'"
),
},
async ({ q, limit, sources }) => {
try {
// Feeds disponibles (sin API key)
const FEEDS: Record<string, string> = {
bbcmundo: "https://feeds.bbci.co.uk/mundo/rss.xml",
elpais:
"https://feeds.elpais.com/mrss-s/pages/ep/site/elpais.com/portada",
reuters: "http://feeds.reuters.com/reuters/topNews",
cnn: "http://rss.cnn.com/rss/edition.rss",
ap: "https://apnews.com/rss", // puede variar por sección
euronews: "https://es.euronews.com/rss?level=theme_137341",
};
const maxResults = Math.max(
1,
Math.min(50, Number.isFinite(Number(limit)) ? Number(limit) : 10)
);
const selectedKeys =
Array.isArray(sources) && sources.length
? sources.filter((k) => FEEDS[k])
: Object.keys(FEEDS);
// Normaliza texto para búsqueda (case-insensitive, sin acentos)
const normalize = (s: string) =>
s
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
const qn = normalize(q);
// Pequeño parser de RSS con regex (sin dependencias)
function parseItems(xml: string) {
const items: {
title: string;
link: string;
description?: string;
pubDate?: string;
}[] = [];
const itemBlocks = Array.from(
xml.matchAll(/<item>([\s\S]*?)<\/item>/gi)
);
for (const m of itemBlocks) {
const block = m[1];
// title (maneja CDATA o texto plano)
const t =
block.match(
/<title><!\[CDATA\[([\s\S]*?)\]\]><\/title>|<title>([\s\S]*?)<\/title>/i
) || [];
const title = (t[1] || t[2] || "").trim();
// link
const l = block.match(/<link>([\s\S]*?)<\/link>/i);
const link = (l?.[1] || "").trim();
// description (opcional)
const d =
block.match(
/<description><!\[CDATA\[([\s\S]*?)\]\]><\/description>|<description>([\s\S]*?)<\/description>/i
) || [];
const description = (d[1] || d[2] || "").trim();
// pubDate (opcional)
const p = block.match(/<pubDate>([\s\S]*?)<\/pubDate>/i);
const pubDate = (p?.[1] || "").trim();
if (title || link) {
items.push({ title, link, description, pubDate });
}
}
return items;
}
// Fetch con timeout
async function fetchWithTimeout(url: string, ms = 8000) {
const ctrl = new AbortController();
const id = setTimeout(() => ctrl.abort(), ms);
try {
const res = await fetch(url, { signal: ctrl.signal });
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return await res.text();
} catch (err) {
throw err;
} finally {
clearTimeout(id);
}
}
// Consultar todas las fuentes seleccionadas en paralelo
const settled = await Promise.allSettled(
selectedKeys.map(async (key) => {
const xml = await fetchWithTimeout(FEEDS[key]);
const items = parseItems(xml);
const filtered = items.filter((it) => {
const haystack = normalize(
`${it.title} ${it.description ?? ""}`.slice(0, 2000)
);
return haystack.includes(qn);
});
// Anotar la fuente
return filtered.map((it) => ({ source: key, ...it }));
})
);
// Aplanar resultados y ordenar por pubDate (si existe)
const aggregated: Array<{
source: string;
title: string;
link: string;
description?: string;
pubDate?: string;
}> = [];
for (const s of settled) {
if (s.status === "fulfilled") aggregated.push(...s.value);
}
aggregated.sort((a, b) => {
const da = Date.parse(a.pubDate || "") || 0;
const db = Date.parse(b.pubDate || "") || 0;
return db - da; // más recientes primero
});
const results = aggregated.slice(0, maxResults);
return {
content: [
{
type: "text",
text: JSON.stringify(
{
query: q,
sources: selectedKeys,
total_found: aggregated.length,
returned: results.length,
results,
},
null,
2
),
},
],
};
} catch (err: any) {
return {
content: [
{
type: "text",
text: JSON.stringify({ error: err.message }),
},
],
};
}
}
);
}