server.py (view raw)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 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()
|