Versao inicial: servidor MCP de noticias via RSS com coletor e banco SQLite
Pablo Murad pablo@pablomurad.com
Sat, 08 Aug 2026 15:07:55 -0300
16 files changed,
1010 insertions(+),
0 deletions(-)
A
.dockerignore
@@ -0,0 +1,10 @@
+.venv/ +__pycache__/ +*.pyc +data/ +news.db +news.db-wal +news.db-shm +.env +.git/ +*.md
A
.env.example
@@ -0,0 +1,16 @@
+# Copie para .env e ajuste. NUNCA versione o .env real (está no .gitignore). + +# Token bearer exigido de cada requisição. Gere um forte, ex.: +# openssl rand -hex 32 +# Deixe VAZIO só em teste local sem proxy (aí o servidor fica aberto). +NEWS_MCP_TOKEN= + +# Porta local (fixa). O nginx faz proxy_pass para 127.0.0.1:NESTA_PORTA. +NEWS_MCP_PORT=17631 + +# Coletor de fundo. +POLL_INTERVAL_MIN=30 +RETENTION_DAYS=90 + +# DB_PATH e FEEDS_PATH são definidos pelo docker-compose (volume /data e +# /app/feeds.json). Rodando fora do Docker, o padrão é ao lado do server.py.
A
.gitignore
@@ -0,0 +1,8 @@
+.venv/ +__pycache__/ +*.pyc +data/ +news.db +news.db-wal +news.db-shm +.env
A
Dockerfile
@@ -0,0 +1,24 @@
+# news-mcp — imagem do servidor MCP de notícias. +FROM python:3.12-slim + +# Sem .pyc (rootfs é read-only no runtime) e logs sem buffer. +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +WORKDIR /app + +# Usuário não-root (segurança: o container não roda como root). +RUN useradd --uid 10001 --create-home appuser + +# Instala dependências primeiro (melhor cache de camadas). +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Código + um feeds.json padrão (o deploy sobrescreve montando o seu por cima). +COPY *.py ./ +COPY feeds.json ./feeds.json + +USER appuser +EXPOSE 17631 + +CMD ["python", "server.py"]
A
README.md
@@ -0,0 +1,39 @@
+# news-mcp + +Servidor MCP de noticias via RSS. Um coletor baixa os feeds do feeds.json de tempos em tempos, guarda tudo num banco SQLite e responde pelas ferramentas do MCP. Nao precisa de token de terceiros nem servico pago. + +Ferramentas: + +- list_sources: lista as fontes por categoria +- get_latest_news: ultimas noticias (todas, por categoria ou por fonte) +- search_news: busca por palavra-chave +- get_stats: total de artigos e hora da ultima coleta + +Categorias das fontes: tech, programming, security, science, linux, windows, news. + +Rodar local: + + python -m venv .venv + .venv\Scripts\activate + pip install -r requirements.txt + python server.py + +O servidor sobe em http://127.0.0.1:17631/mcp. O coletor roda uma vez ao subir e depois a cada 30 minutos. + +Rodar com Docker: + + cp .env.example .env + (edite o .env e defina NEWS_MCP_TOKEN) + mkdir -p data + docker compose up -d --build + +A porta fica presa em 127.0.0.1:17631, atras de um proxy reverso (nginx) com HTTPS. O banco fica em ./data/news.db e persiste. Editar o feeds.json vale na proxima coleta, sem rebuild. + +Configuracao pelo .env: NEWS_MCP_TOKEN, NEWS_MCP_PORT, POLL_INTERVAL_MIN, RETENTION_DAYS. + +Trocar as fontes: edite o feeds.json. Cada fonte tem id, name, url e category. + +Conectar no cliente MCP (transporte http com bearer token): + + news https://mcpnews.grupomurad.net/mcp + header: Authorization: Bearer SEU_TOKEN
A
collector.py
@@ -0,0 +1,28 @@
+import asyncio +import logging + +import config +import db +from feeds import load_feeds +from fetch import fetch_all + +log = logging.getLogger("news_mcp.collector") + + +async def poll_once() -> int: + feeds = load_feeds() + articles = await fetch_all(feeds) + inserted = await asyncio.to_thread(db.upsert_articles, articles) + removed = await asyncio.to_thread(db.prune, config.RETENTION_DAYS) + log.info("coleta: %d feeds, %d artigos baixados, %d novos, %d podados", + len(feeds), len(articles), inserted, removed) + return inserted + + +async def run_collector() -> None: + while True: + try: + await poll_once() + except Exception: + log.exception("falha na coleta; tentando de novo no próximo ciclo") + await asyncio.sleep(config.POLL_INTERVAL_MIN * 60)
A
config.py
@@ -0,0 +1,17 @@
+import os +from pathlib import Path + +_BASE = Path(__file__).parent + +HTTP_TIMEOUT = 10 +HTTP_HEADERS = {"User-Agent": "Mozilla/5.0 (news-mcp)"} + +FEEDS_PATH = Path(os.environ.get("FEEDS_PATH", _BASE / "feeds.json")) +DB_PATH = Path(os.environ.get("DB_PATH", _BASE / "news.db")) + +HOST = os.environ.get("NEWS_MCP_HOST", "127.0.0.1") +PORT = int(os.environ.get("NEWS_MCP_PORT", "17631")) +TOKEN = os.environ.get("NEWS_MCP_TOKEN", "") + +POLL_INTERVAL_MIN = int(os.environ.get("POLL_INTERVAL_MIN", "30")) +RETENTION_DAYS = int(os.environ.get("RETENTION_DAYS", "90"))
A
db.py
@@ -0,0 +1,138 @@
+import sqlite3 +from datetime import datetime, timezone, timedelta +from typing import Optional, List, Dict, Any + +from config import DB_PATH + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS articles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + feed_id TEXT NOT NULL, + source TEXT NOT NULL, + category TEXT NOT NULL, + guid TEXT NOT NULL, + title TEXT NOT NULL, + link TEXT, + summary TEXT, + published TEXT, + fetched_at TEXT NOT NULL, + UNIQUE(feed_id, guid) +); +CREATE INDEX IF NOT EXISTS idx_cat_pub ON articles(category, published DESC); +CREATE INDEX IF NOT EXISTS idx_pub ON articles(published DESC); +""" + + +def _connect() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL;") + return conn + + +def init_db() -> None: + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + with _connect() as conn: + conn.executescript(_SCHEMA) + + +def _iso(dt: Optional[datetime]) -> Optional[str]: + return dt.isoformat() if dt else None + + +def _from_iso(s: Optional[str]) -> Optional[datetime]: + if not s: + return None + try: + return datetime.fromisoformat(s) + except ValueError: + return None + + +def upsert_articles(articles: List[Dict[str, Any]]) -> int: + if not articles: + return 0 + now = datetime.now(timezone.utc).isoformat() + rows = [ + (a["feed_id"], a["source"], a["category"], a["guid"], a["title"], + a.get("link"), a.get("summary"), _iso(a.get("published")), now) + for a in articles + ] + with _connect() as conn: + before = conn.total_changes + conn.executemany( + "INSERT OR IGNORE INTO articles " + "(feed_id, source, category, guid, title, link, summary, published, fetched_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + rows, + ) + return conn.total_changes - before + + +def _rows_to_dicts(rows) -> List[Dict[str, Any]]: + return [ + { + "source": r["source"], + "category": r["category"], + "title": r["title"], + "link": r["link"], + "summary": r["summary"], + "published": _from_iso(r["published"]), + } + for r in rows + ] + + +_ORDER = " ORDER BY published IS NULL, published DESC LIMIT ?" + + +def latest(category: Optional[str], source_id: Optional[str], limit: int) -> List[Dict[str, Any]]: + sql = "SELECT * FROM articles" + where, params = [], [] + if category: + where.append("category = ?") + params.append(category) + if source_id: + where.append("feed_id = ?") + params.append(source_id) + if where: + sql += " WHERE " + " AND ".join(where) + sql += _ORDER + params.append(limit) + with _connect() as conn: + return _rows_to_dicts(conn.execute(sql, params).fetchall()) + + +def search(keyword: str, category: Optional[str], limit: int) -> List[Dict[str, Any]]: + like = f"%{keyword}%" + sql = "SELECT * FROM articles WHERE (title LIKE ? OR summary LIKE ?)" + params: List[Any] = [like, like] + if category: + sql += " AND category = ?" + params.append(category) + sql += _ORDER + params.append(limit) + with _connect() as conn: + return _rows_to_dicts(conn.execute(sql, params).fetchall()) + + +def prune(days: int) -> int: + cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat() + with _connect() as conn: + before = conn.total_changes + conn.execute("DELETE FROM articles WHERE fetched_at < ?", (cutoff,)) + return conn.total_changes - before + + +def stats() -> Dict[str, Any]: + with _connect() as conn: + total = conn.execute("SELECT COUNT(*) FROM articles").fetchone()[0] + by_cat = conn.execute( + "SELECT category, COUNT(*) AS c FROM articles GROUP BY category ORDER BY category" + ).fetchall() + last = conn.execute("SELECT MAX(fetched_at) FROM articles").fetchone()[0] + return { + "total": total, + "by_category": {r["category"]: r["c"] for r in by_cat}, + "last_fetch": last, + }
A
docker-compose.yml
@@ -0,0 +1,27 @@
+services: + news-mcp: + build: . + image: news-mcp + container_name: news-mcp + env_file: .env + environment: + # Dentro do container escuta em todas as interfaces; a exposição fica + # restrita ao localhost da VPS pelo mapeamento de portas abaixo. + NEWS_MCP_HOST: "0.0.0.0" + DB_PATH: "/data/news.db" + FEEDS_PATH: "/app/feeds.json" + ports: + # 127.0.0.1 → só o nginx (mesma máquina) alcança. NUNCA use 0.0.0.0 aqui. + - "127.0.0.1:17631:17631" + volumes: + - ./data:/data # banco persistente (news.db + WAL) + - ./feeds.json:/app/feeds.json:ro # edite as fontes sem rebuild + restart: unless-stopped + # ── Endurecimento ── + read_only: true + tmpfs: + - /tmp + cap_drop: + - ALL + security_opt: + - no-new-privileges:true
A
feeds.json
@@ -0,0 +1,400 @@
+{ + "feeds": [ + { + "id": "tecnoblog", + "name": "Tecnoblog", + "url": "https://tecnoblog.net/feed/", + "category": "tech" + }, + { + "id": "bbc_tech", + "name": "BBC Technology", + "url": "https://feeds.bbci.co.uk/news/technology/rss.xml", + "category": "tech" + }, + { + "id": "lemmy_technology", + "name": "Lemmy Technology (lemmy.world)", + "url": "https://lemmy.world/feeds/c/technology.xml?sort=Active", + "category": "tech" + }, + { + "id": "beehaw_tech", + "name": "Beehaw Technology (beehaw.org)", + "url": "https://beehaw.org/feeds/c/technology.xml?sort=Active", + "category": "tech" + }, + { + "id": "lemmy_selfhosted", + "name": "Lemmy Selfhosted (lemmy.world)", + "url": "https://lemmy.world/feeds/c/selfhosted.xml?sort=Active", + "category": "tech" + }, + { + "id": "lemmy_opensource", + "name": "Lemmy Open Source (lemmy.ml)", + "url": "https://lemmy.ml/feeds/c/opensource.xml?sort=Active", + "category": "tech" + }, + { + "id": "hackernews", + "name": "Hacker News", + "url": "https://hnrss.org/frontpage", + "category": "programming" + }, + { + "id": "lobsters", + "name": "Lobsters", + "url": "https://lobste.rs/rss", + "category": "programming" + }, + { + "id": "lemmy_programming", + "name": "Lemmy Programming (programming.dev)", + "url": "https://programming.dev/feeds/c/programming.xml?sort=Active", + "category": "programming" + }, + { + "id": "stackoverflow_blog", + "name": "Stack Overflow Blog", + "url": "https://stackoverflow.blog/feed/", + "category": "programming" + }, + { + "id": "infoq", + "name": "InfoQ", + "url": "https://feed.infoq.com/", + "category": "programming" + }, + { + "id": "martin_fowler", + "name": "Martin Fowler", + "url": "https://martinfowler.com/feed.atom", + "category": "programming" + }, + { + "id": "jetbrains", + "name": "JetBrains Blog", + "url": "https://blog.jetbrains.com/feed/", + "category": "programming" + }, + { + "id": "freecodecamp", + "name": "freeCodeCamp News", + "url": "https://www.freecodecamp.org/news/rss/", + "category": "programming" + }, + { + "id": "css_tricks", + "name": "CSS-Tricks", + "url": "https://css-tricks.com/feed/", + "category": "programming" + }, + { + "id": "smashing", + "name": "Smashing Magazine", + "url": "https://www.smashingmagazine.com/feed/", + "category": "programming" + }, + { + "id": "krebs", + "name": "KrebsOnSecurity", + "url": "https://krebsonsecurity.com/feed/", + "category": "security" + }, + { + "id": "schneier", + "name": "Schneier on Security", + "url": "https://www.schneier.com/feed/atom/", + "category": "security" + }, + { + "id": "bleepingcomputer", + "name": "BleepingComputer", + "url": "https://www.bleepingcomputer.com/feed/", + "category": "security" + }, + { + "id": "thehackernews", + "name": "The Hacker News", + "url": "https://feeds.feedburner.com/TheHackersNews", + "category": "security" + }, + { + "id": "cisa", + "name": "CISA Cybersecurity Advisories", + "url": "https://www.cisa.gov/cybersecurity-advisories/all.xml", + "category": "security" + }, + { + "id": "sans_isc", + "name": "SANS Internet Storm Center", + "url": "https://isc.sans.edu/rssfeed.xml", + "category": "security" + }, + { + "id": "portswigger", + "name": "PortSwigger Research", + "url": "https://portswigger.net/research/rss", + "category": "security" + }, + { + "id": "project_zero", + "name": "Google Project Zero", + "url": "https://googleprojectzero.blogspot.com/feeds/posts/default", + "category": "security" + }, + { + "id": "talos", + "name": "Cisco Talos Intelligence", + "url": "https://blog.talosintelligence.com/rss/", + "category": "security" + }, + { + "id": "ms_security", + "name": "Microsoft Security Blog", + "url": "https://www.microsoft.com/en-us/security/blog/feed/", + "category": "security" + }, + { + "id": "lemmy_hacking", + "name": "Lemmy Hacking (lemmy.ml)", + "url": "https://lemmy.ml/feeds/c/hacking.xml?sort=Active", + "category": "security" + }, + { + "id": "lemmy_privacy", + "name": "Lemmy Privacy (lemmy.ml)", + "url": "https://lemmy.ml/feeds/c/privacy.xml?sort=Active", + "category": "security" + }, + { + "id": "science_news", + "name": "Science News", + "url": "https://www.sciencenews.org/feed", + "category": "science" + }, + { + "id": "quanta", + "name": "Quanta Magazine", + "url": "https://www.quantamagazine.org/feed/", + "category": "science" + }, + { + "id": "fapesp", + "name": "Revista Pesquisa FAPESP", + "url": "https://revistapesquisa.fapesp.br/feed/", + "category": "science" + }, + { + "id": "nasa", + "name": "NASA", + "url": "https://www.nasa.gov/feed/", + "category": "science" + }, + { + "id": "ars_science", + "name": "Ars Technica Science", + "url": "https://feeds.arstechnica.com/arstechnica/science", + "category": "science" + }, + { + "id": "sciencedaily", + "name": "ScienceDaily", + "url": "https://www.sciencedaily.com/rss/all.xml", + "category": "science" + }, + { + "id": "phys_org", + "name": "Phys.org", + "url": "https://phys.org/rss-feed/", + "category": "science" + }, + { + "id": "lwn", + "name": "LWN.net", + "url": "https://lwn.net/headlines/rss", + "category": "linux" + }, + { + "id": "phoronix", + "name": "Phoronix", + "url": "https://www.phoronix.com/rss.php", + "category": "linux" + }, + { + "id": "linuxiac", + "name": "Linuxiac", + "url": "https://linuxiac.com/feed/", + "category": "linux" + }, + { + "id": "itsfoss", + "name": "It's FOSS", + "url": "https://itsfoss.com/rss/", + "category": "linux" + }, + { + "id": "distrowatch", + "name": "DistroWatch", + "url": "https://distrowatch.com/news/dw.xml", + "category": "linux" + }, + { + "id": "arch_news", + "name": "Arch Linux News", + "url": "https://archlinux.org/feeds/news/", + "category": "linux" + }, + { + "id": "fedora_magazine", + "name": "Fedora Magazine", + "url": "https://fedoramagazine.org/feed/", + "category": "linux" + }, + { + "id": "planet_kde", + "name": "Planet KDE", + "url": "https://planet.kde.org/rss20.xml", + "category": "linux" + }, + { + "id": "gnome_weekly", + "name": "This Week in GNOME", + "url": "https://thisweek.gnome.org/index.xml", + "category": "linux" + }, + { + "id": "freebsd_news", + "name": "FreeBSD News", + "url": "https://www.freebsd.org/news/feed.xml", + "category": "linux" + }, + { + "id": "openbsd_journal", + "name": "OpenBSD Journal", + "url": "https://undeadly.org/cgi?action=rss", + "category": "linux" + }, + { + "id": "diolinux", + "name": "Diolinux", + "url": "https://diolinux.com.br/feed", + "category": "linux" + }, + { + "id": "lemmy_linux", + "name": "Lemmy Linux (lemmy.ml)", + "url": "https://lemmy.ml/feeds/c/linux.xml?sort=Active", + "category": "linux" + }, + { + "id": "tchncs_linux", + "name": "Linux (discuss.tchncs.de)", + "url": "https://discuss.tchncs.de/feeds/c/linux.xml?sort=Active", + "category": "linux" + }, + { + "id": "windows_blog", + "name": "Windows Blog", + "url": "https://blogs.windows.com/feed/", + "category": "windows" + }, + { + "id": "windows_central", + "name": "Windows Central", + "url": "https://www.windowscentral.com/feeds.xml", + "category": "windows" + }, + { + "id": "neowin", + "name": "Neowin", + "url": "https://www.neowin.net/news/rss/", + "category": "windows" + }, + { + "id": "thurrott", + "name": "Thurrott", + "url": "https://www.thurrott.com/feed", + "category": "windows" + }, + { + "id": "ghacks", + "name": "gHacks Technology News", + "url": "https://www.ghacks.net/feed/", + "category": "windows" + }, + { + "id": "majorgeeks", + "name": "MajorGeeks", + "url": "https://www.majorgeeks.com/files/rss", + "category": "windows" + }, + { + "id": "patchmypc", + "name": "Patch My PC Blog", + "url": "https://patchmypc.com/feed", + "category": "windows" + }, + { + "id": "dispatch", + "name": "The Dispatch", + "url": "https://thedispatch.com/feed/", + "category": "news" + }, + { + "id": "national_review", + "name": "National Review", + "url": "https://www.nationalreview.com/feed/", + "category": "news" + }, + { + "id": "reason", + "name": "Reason", + "url": "https://reason.com/feed/", + "category": "news" + }, + { + "id": "free_press", + "name": "The Free Press", + "url": "https://www.thefp.com/feed", + "category": "news" + }, + { + "id": "fox_news", + "name": "Fox News", + "url": "https://moxie.foxnews.com/google-publisher/latest.xml", + "category": "news" + }, + { + "id": "ny_post", + "name": "New York Post", + "url": "https://nypost.com/feed/", + "category": "news" + }, + { + "id": "daily_wire", + "name": "The Daily Wire", + "url": "https://www.dailywire.com/feeds/rss.xml", + "category": "news" + }, + { + "id": "jovem_pan", + "name": "Jovem Pan", + "url": "https://jovempan.com.br/feed", + "category": "news" + }, + { + "id": "conexao_politica", + "name": "Conexão Política", + "url": "https://www.conexaopolitica.com.br/feed/", + "category": "news" + }, + { + "id": "diario_do_poder", + "name": "Diário do Poder", + "url": "https://diariodopoder.com.br/feed", + "category": "news" + } + ] +}
A
feeds.py
@@ -0,0 +1,28 @@
+import json +from typing import Optional, List, Dict, Tuple + +from config import FEEDS_PATH + + +def load_feeds() -> List[Dict[str, str]]: + if not FEEDS_PATH.exists(): + return [] + data = json.loads(FEEDS_PATH.read_text(encoding="utf-8")) + return data.get("feeds", []) + + +def categories(feeds: List[Dict[str, str]]) -> List[str]: + return sorted({f.get("category", "geral") for f in feeds}) + + +def filter_category( + feeds: List[Dict[str, str]], category: Optional[str] +) -> Tuple[List[Dict[str, str]], Optional[str]]: + if not category: + return feeds, None + cat = category.strip().lower() + subset = [f for f in feeds if f.get("category", "geral") == cat] + if not subset: + validas = ", ".join(categories(feeds)) + return [], f"Categoria '{category}' não existe. Disponíveis: {validas}." + return subset, None
A
fetch.py
@@ -0,0 +1,67 @@
+import re +import asyncio +import hashlib +from datetime import datetime, timezone +from typing import Optional, List, Dict, Any + +import httpx +import feedparser + +from config import HTTP_TIMEOUT, HTTP_HEADERS + + +def clean_summary(raw: str, limit: int = 300) -> str: + text = re.sub(r"<[^>]+>", "", raw or "").strip() + return text[:limit] + ("…" if len(text) > limit else "") + + +def parse_date(struct_time) -> Optional[datetime]: + if not struct_time: + return None + return datetime(*struct_time[:6], tzinfo=timezone.utc) + + +def _guid(entry: Any, link: str, title: str, published: Optional[datetime]) -> str: + raw = entry.get("id") or entry.get("guid") or link + if raw: + return raw + base = f"{title}|{published.isoformat() if published else ''}" + return "sha1:" + hashlib.sha1(base.encode("utf-8")).hexdigest() + + +async def fetch_feed( + client: httpx.AsyncClient, feed: Dict[str, str] +) -> List[Dict[str, Any]]: + try: + resp = await client.get(feed["url"], headers=HTTP_HEADERS, + timeout=HTTP_TIMEOUT, follow_redirects=True) + resp.raise_for_status() + except (httpx.HTTPError, httpx.TimeoutException): + return [] + + parsed = feedparser.parse(resp.content) + + items: List[Dict[str, Any]] = [] + for entry in parsed.entries: + link = entry.get("link", "") + title = entry.get("title", "(sem título)") + published = parse_date( + entry.get("published_parsed") or entry.get("updated_parsed") + ) + items.append({ + "feed_id": feed["id"], + "source": feed["name"], + "category": feed.get("category", "geral"), + "guid": _guid(entry, link, title, published), + "title": title, + "link": link, + "summary": clean_summary(entry.get("summary", "")), + "published": published, + }) + return items + + +async def fetch_all(feeds: List[Dict[str, str]]) -> List[Dict[str, Any]]: + async with httpx.AsyncClient() as client: + results = await asyncio.gather(*[fetch_feed(client, f) for f in feeds]) + return [item for sublist in results for item in sublist]
A
formatting.py
@@ -0,0 +1,24 @@
+from datetime import datetime, timezone +from typing import List, Dict, Any + +_MIN = datetime.min.replace(tzinfo=timezone.utc) + + +def sort_by_date(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + return sorted(items, key=lambda x: x.get("published") or _MIN, reverse=True) + + +def format_items(items: List[Dict[str, Any]]) -> str: + if not items: + return "Nenhuma notícia encontrada." + linhas = [] + for it in items: + pub = it.get("published") + data = pub.strftime("%d/%m %H:%M") if pub else "s/ data" + linhas.append( + f"### {it['title']}\n" + f"**{it['source']}** · {data}\n" + f"{it.get('summary', '')}\n" + f"{it.get('link', '')}" + ) + return "\n\n".join(linhas)
A
models.py
@@ -0,0 +1,29 @@
+from typing import Optional +from pydantic import BaseModel, Field, ConfigDict + +_CATEGORY_DESC = ( + "Optional category to filter by: 'tech', 'programming', 'security', " + "'science', 'linux', 'windows' or 'news'. Omit for all categories." +) + + +class LatestInput(BaseModel): + model_config = ConfigDict(str_strip_whitespace=True, extra="forbid") + + category: Optional[str] = Field(default=None, description=_CATEGORY_DESC) + source_id: Optional[str] = Field( + default=None, + description="Optional feed id to filter by (e.g. 'tecnoblog'). Omit for all feeds.", + ) + limit: int = Field(default=10, ge=1, le=50, + description="Max number of articles to return.") + + +class SearchInput(BaseModel): + model_config = ConfigDict(str_strip_whitespace=True, extra="forbid") + + keyword: str = Field(..., min_length=2, max_length=100, + description="Keyword to search for in titles and summaries.") + category: Optional[str] = Field(default=None, description=_CATEGORY_DESC) + limit: int = Field(default=10, ge=1, le=50, + description="Max number of articles to return.")
A
server.py
@@ -0,0 +1,150 @@
+import asyncio +import logging +from contextlib import asynccontextmanager + +import uvicorn +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse +from mcp.server.fastmcp import FastMCP + +import config +import db +from feeds import load_feeds, categories, filter_category +from formatting import format_items +from models import LatestInput, SearchInput +from collector import run_collector + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(name)s %(levelname)s %(message)s", +) +log = logging.getLogger("news_mcp") + + +@asynccontextmanager +async def lifespan(_server: FastMCP): + db.init_db() + task = asyncio.create_task(run_collector()) + log.info("news_mcp no ar; coletor rodando a cada %d min", config.POLL_INTERVAL_MIN) + try: + yield + finally: + task.cancel() + + +mcp = FastMCP("news_mcp", lifespan=lifespan) + + +@mcp.tool( + name="list_sources", + annotations={"title": "List configured news sources", + "readOnlyHint": True, "openWorldHint": False}, +) +async def list_sources() -> str: + """List all RSS news sources configured, grouped by category. + + Returns each source's id (used for filtering) and display name, plus the + category names usable in the `category` parameter of the other tools. + """ + feeds = load_feeds() + if not feeds: + return "Nenhum feed configurado. Edite o feeds.json e adicione fontes." + + linhas = [] + for cat in categories(feeds): + do_grupo = [f for f in feeds if f.get("category", "geral") == cat] + linhas.append(f"## {cat} ({len(do_grupo)} fontes)") + linhas.extend(f"- {f['name']} (id: `{f['id']}`)" for f in do_grupo) + linhas.append("") + return "\n".join(linhas).strip() + + +@mcp.tool( + name="get_latest_news", + annotations={"title": "Get latest news", "readOnlyHint": True, "openWorldHint": True}, +) +async def get_latest_news(params: LatestInput) -> str: + """Get the most recent news articles collected from the RSS feeds. + + Reads from the local store (updated in the background), optionally filtered + by category and/or a single source_id, newest first, up to `limit`. + """ + feeds = load_feeds() + + _, erro = filter_category(feeds, params.category) + if erro: + return erro + + if params.source_id and not any(f["id"] == params.source_id for f in feeds): + return (f"Fonte '{params.source_id}' não existe. " + f"Use list_sources para ver as disponíveis.") + + items = await asyncio.to_thread( + db.latest, params.category, params.source_id, params.limit + ) + return format_items(items) + + +@mcp.tool( + name="search_news", + annotations={"title": "Search news by keyword", "readOnlyHint": True, "openWorldHint": True}, +) +async def search_news(params: SearchInput) -> str: + """Search the collected news for a keyword. + + Matches the keyword (case-insensitive) in the article title or summary, + optionally restricted to one category, newest first, up to `limit`. Because + it reads history, it can find articles older than the feeds' current window. + """ + _, erro = filter_category(load_feeds(), params.category) + if erro: + return erro + + items = await asyncio.to_thread( + db.search, params.keyword, params.category, params.limit + ) + if not items: + return f"Nada encontrado para '{params.keyword}'." + return format_items(items) + + +@mcp.tool( + name="get_stats", + annotations={"title": "News store statistics", + "readOnlyHint": True, "openWorldHint": False}, +) +async def get_stats() -> str: + """Show how many articles are stored, the breakdown by category, and when + the last background collection ran. Useful to check the server is healthy. + """ + s = await asyncio.to_thread(db.stats) + por_cat = "\n".join(f"- {cat}: {n}" for cat, n in s["by_category"].items()) + return ( + f"**Total de artigos:** {s['total']}\n" + f"**Última coleta:** {s['last_fetch'] or 'ainda não coletou'}\n\n" + f"**Por categoria:**\n{por_cat or '- (vazio)'}" + ) + + +class BearerAuthMiddleware(BaseHTTPMiddleware): + def __init__(self, app, token: str): + super().__init__(app) + self._expected = f"Bearer {token}" if token else "" + + async def dispatch(self, request: Request, call_next): + if self._expected and request.headers.get("authorization") != self._expected: + return JSONResponse({"error": "unauthorized"}, status_code=401) + return await call_next(request) + + +def main() -> None: + if not config.TOKEN: + log.warning("NEWS_MCP_TOKEN vazio: servidor SEM autenticação (ok só em dev local).") + app = mcp.streamable_http_app() + app.add_middleware(BearerAuthMiddleware, token=config.TOKEN) + uvicorn.run(app, host=config.HOST, port=config.PORT, log_level="info") + + +if __name__ == "__main__": + main()