This commit is contained in:
tkk
2026-02-26 14:11:45 +08:00
parent 9b753e071b
commit dd9d984473
20 changed files with 3238 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""OpenNews MCP Server - Crypto news via 6551 REST/WebSocket API."""
+3
View File
@@ -0,0 +1,3 @@
from opennews_mcp.server import main
main()
+156
View File
@@ -0,0 +1,156 @@
"""HTTP/WebSocket client for the 6551 news platform API."""
import asyncio
import json
import time
import logging
from typing import Any, Optional
import httpx
from opennews_mcp.config import API_BASE_URL, WSS_URL, API_TOKEN
logger = logging.getLogger(__name__)
MAX_RETRIES = 2
class NewsAPIClient:
"""Async HTTP client for the 6551 news REST API."""
def __init__(self, base_url: str = API_BASE_URL, token: str = API_TOKEN):
self.base_url = base_url.rstrip("/")
self.token = token
self._client: Optional[httpx.AsyncClient] = None
def _headers(self) -> dict:
return {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
}
async def _get_client(self) -> httpx.AsyncClient:
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(30.0),
headers=self._headers(),
)
return self._client
async def _reset_client(self):
"""Force close and recreate the HTTP client."""
if self._client and not self._client.is_closed:
await self._client.aclose()
self._client = None
async def close(self):
await self._reset_client()
# ---------- internal request with retry ----------
async def _request(self, method: str, url: str, **kwargs) -> httpx.Response:
"""Execute an HTTP request with automatic retry on connection errors."""
last_exc = None
for attempt in range(MAX_RETRIES + 1):
try:
client = await self._get_client()
resp = await client.request(method, url, **kwargs)
resp.raise_for_status()
return resp
except (httpx.ConnectError, httpx.RemoteProtocolError) as e:
last_exc = e
logger.warning(
"Connection error (attempt %d/%d): %s",
attempt + 1, MAX_RETRIES + 1, repr(e),
)
await self._reset_client()
except httpx.HTTPStatusError:
raise
raise last_exc # type: ignore[misc]
# ---------- REST endpoints ----------
async def get_engine_tree(self) -> dict:
"""GET /open/news_type — 获取所有新闻源分类"""
resp = await self._request("GET", f"{self.base_url}/open/news_type")
return resp.json()
async def search_news(
self,
coins: Optional[list[str]] = None,
query: Optional[str] = None,
engine_types: Optional[dict[str, list[str]]] = None,
has_coin: bool = False,
limit: int = 20,
page: int = 1,
) -> dict:
"""POST /open/news_search — 搜索新闻文章"""
body: dict[str, Any] = {"limit": limit, "page": page}
if coins:
body["coins"] = coins
if query:
body["q"] = query
if engine_types:
body["engineTypes"] = engine_types
if has_coin:
body["hasCoin"] = has_coin
resp = await self._request("POST", f"{self.base_url}/open/news_search", json=body)
return resp.json()
class NewsWSClient:
"""WebSocket client for real-time news subscription."""
def __init__(self, wss_url: str = WSS_URL, token: str = API_TOKEN):
self.wss_url = f"{wss_url}?token={token}"
self._ws = None
self._request_id = 0
def _next_id(self) -> str:
self._request_id += 1
return f"req_{self._request_id}_{int(time.time())}"
async def connect(self):
import websockets
self._ws = await websockets.connect(self.wss_url)
async def close(self):
if self._ws:
await self._ws.close()
self._ws = None
async def subscribe_latest(
self,
engine_types: Optional[dict[str, list[str]]] = None,
coins: Optional[list[str]] = None,
has_coin: bool = False,
) -> dict:
"""订阅新闻推送,支持过滤器"""
if not self._ws:
await self.connect()
req_id = self._next_id()
params: dict[str, Any] = {}
if engine_types:
params["engineTypes"] = engine_types
if coins:
params["coins"] = coins
if has_coin:
params["hasCoin"] = has_coin
msg = {"method": "news.subscribe", "id": req_id, "params": params}
await self._ws.send(json.dumps(msg))
resp = await self._ws.recv()
return json.loads(resp)
async def receive_news(self, timeout: float = 10.0) -> Optional[dict]:
if not self._ws:
return None
try:
msg = await asyncio.wait_for(self._ws.recv(), timeout=timeout)
return json.loads(msg)
except asyncio.TimeoutError:
return None
except Exception as e:
logger.warning("WebSocket receive error: %s", repr(e))
return None
+54
View File
@@ -0,0 +1,54 @@
"""FastMCP application instance, lifespan, and knowledge resources."""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from pathlib import Path
from mcp.server.fastmcp import FastMCP
from opennews_mcp.api_client import NewsAPIClient, NewsWSClient
# Knowledge directory (project root / knowledge)
KNOWLEDGE_DIR = Path(__file__).resolve().parent.parent.parent / "knowledge"
@dataclass
class AppContext:
"""Shared application state available to all tools via ctx."""
api: NewsAPIClient
ws: NewsWSClient
@asynccontextmanager
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
"""Manage the API client lifecycle."""
api = NewsAPIClient()
ws = NewsWSClient()
try:
yield AppContext(api=api, ws=ws)
finally:
await api.close()
await ws.close()
# ---------- FastMCP instance ----------
mcp = FastMCP(
"opennews-6551",
lifespan=app_lifespan,
json_response=True,
)
# ---------- Knowledge resources ----------
def _read_knowledge(name: str) -> str:
path = KNOWLEDGE_DIR / name
if path.exists():
return path.read_text(encoding="utf-8")
return f"Knowledge file '{name}' not found."
@mcp.resource("knowledge://guide")
async def knowledge_guide() -> str:
"""Usage guide — tool workflows, search strategies, best practices."""
return _read_knowledge("guide.md")
+57
View File
@@ -0,0 +1,57 @@
"""Configuration and shared utilities for the OpenNews MCP server.
Reads settings from config.json at project root. Environment variables
can still override any value.
"""
import json
import os
from datetime import datetime, date
from decimal import Decimal
from pathlib import Path
# ---------- Load config.json ----------
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
_CONFIG_PATH = _PROJECT_ROOT / "config.json"
_cfg: dict = {}
if _CONFIG_PATH.exists():
with open(_CONFIG_PATH, "r", encoding="utf-8") as f:
_cfg = json.load(f)
# ---------- API (env vars take precedence) ----------
API_BASE_URL = os.environ.get("OPENNEWS_API_BASE") or _cfg.get("api_base_url", "")
WSS_URL = os.environ.get("OPENNEWS_WSS_URL") or _cfg.get("wss_url", "")
API_TOKEN = os.environ.get("OPENNEWS_TOKEN") or _cfg.get("api_token", "")
# 检查 token 是否配置
if not API_TOKEN:
raise ValueError(
"OPENNEWS_TOKEN 未配置。请前往 https://6551.io/mcp 申请 API Token"
"然后设置环境变量 OPENNEWS_TOKEN 或在 config.json 中配置 api_token。"
)
# ---------- Safety ----------
MAX_ROWS = int(os.environ.get("OPENNEWS_MAX_ROWS", 0) or _cfg.get("max_rows", 100))
def clamp_limit(limit: int) -> int:
"""Clamp user-supplied limit to [1, MAX_ROWS]."""
return min(max(1, limit), MAX_ROWS)
def make_serializable(obj):
"""Recursively convert non-JSON-serializable types."""
if obj is None:
return None
if isinstance(obj, dict):
return {k: make_serializable(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [make_serializable(item) for item in obj]
if isinstance(obj, (datetime, date)):
return obj.isoformat()
if isinstance(obj, Decimal):
return float(obj)
if isinstance(obj, bytes):
return obj.decode("utf-8", errors="replace")
return obj
+24
View File
@@ -0,0 +1,24 @@
"""Entry point for the OpenNews MCP server."""
import sys
# psycopg3 async requires SelectorEventLoop on Windows (ProactorEventLoop is unsupported).
if sys.platform == "win32":
import asyncio, selectors # noqa: E401
asyncio.set_event_loop_policy(
asyncio.WindowsSelectorEventLoopPolicy()
)
from opennews_mcp.app import mcp
# Importing the tools package triggers registration of all @mcp.tool() decorators.
import opennews_mcp.tools # noqa: F401
def main():
"""Run the MCP server (stdio transport by default)."""
mcp.run()
if __name__ == "__main__":
main()
+8
View File
@@ -0,0 +1,8 @@
"""Import all tool modules to register their @mcp.tool() decorators.
Each module is self-contained and atomic — no inter-tool dependencies.
"""
from opennews_mcp.tools import discovery # noqa: F401
from opennews_mcp.tools import news # noqa: F401
from opennews_mcp.tools import realtime # noqa: F401
+79
View File
@@ -0,0 +1,79 @@
"""Discovery tools — list available news sources and categories."""
from mcp.server.fastmcp import Context
from opennews_mcp.app import mcp
@mcp.tool()
async def get_news_sources(ctx: Context) -> dict:
"""Get all available news source categories and their metadata.
Returns a tree structure with engine types (news, listing, onchain, meme, market)
and their sub-categories (Bloomberg, Reuters, Binance, etc.).
Use this first to understand what news sources are available before searching.
"""
api = ctx.request_context.lifespan_context.api
try:
result = await api.get_engine_tree()
data = result.get("data", [])
# Build a simplified summary
sources = []
for engine in data:
categories = []
for cat in engine.get("categories", []):
categories.append({
"code": cat.get("code"),
"name": cat.get("name"),
"enName": cat.get("enName"),
"aiEnabled": cat.get("aiEnabled", False),
})
sources.append({
"code": engine.get("code"),
"name": engine.get("name"),
"enName": engine.get("enName"),
"category_count": len(categories),
"categories": categories,
})
return {
"success": True,
"data": sources,
"engine_count": len(sources),
}
except Exception as e:
return {"success": False, "error": str(e) or repr(e)}
@mcp.tool()
async def list_news_types(ctx: Context) -> dict:
"""List all available news type codes for filtering.
Returns a flat list of news source codes that can be used with
the newsType parameter in search_news.
"""
api = ctx.request_context.lifespan_context.api
try:
result = await api.get_engine_tree()
data = result.get("data", [])
types = []
for engine in data:
for cat in engine.get("categories", []):
types.append({
"code": cat.get("code"),
"engineType": engine.get("code"),
"name": cat.get("enName") or cat.get("name"),
})
return {
"success": True,
"data": types,
"count": len(types),
}
except Exception as e:
return {"success": False, "error": str(e) or repr(e)}
+227
View File
@@ -0,0 +1,227 @@
"""News content tools — search and retrieve crypto news via REST API.
Uses POST /open/news_search as the primary data source.
Returns raw article data as-is from the API.
"""
from mcp.server.fastmcp import Context
from opennews_mcp.app import mcp
from opennews_mcp.config import clamp_limit, make_serializable, MAX_ROWS
@mcp.tool()
async def get_latest_news(ctx: Context, limit: int = 10) -> dict:
"""Get the most recent crypto news articles, newest first.
Returns news with title text, source, link, related coins, AI rating, and tags.
Args:
limit: Maximum number of articles to return (default 10, max 100).
"""
api = ctx.request_context.lifespan_context.api
limit = clamp_limit(limit)
try:
result = await api.search_news(limit=limit, page=1)
data = result.get("data", [])[:limit]
return make_serializable({
"success": True, "data": data,
"count": len(data), "total": result.get("total", 0),
})
except Exception as e:
return {"success": False, "error": str(e) or repr(e)}
@mcp.tool()
async def search_news(keyword: str, ctx: Context, limit: int = 10) -> dict:
"""Search crypto news by keyword in text content.
Args:
keyword: Search term (e.g. "bitcoin", "SEC", "ETF").
limit: Maximum results (default 10, max 100).
"""
api = ctx.request_context.lifespan_context.api
limit = clamp_limit(limit)
try:
result = await api.search_news(query=keyword, limit=limit, page=1)
data = result.get("data", [])[:limit]
return make_serializable({
"success": True, "keyword": keyword, "data": data,
"count": len(data), "total": result.get("total", 0),
})
except Exception as e:
return {"success": False, "error": str(e) or repr(e)}
@mcp.tool()
async def search_news_by_coin(coin: str, ctx: Context, limit: int = 10) -> dict:
"""Search news related to a specific cryptocurrency coin/token.
Args:
coin: Coin symbol or name (e.g. "BTC", "ETH", "SOL", "TRUMP").
limit: Maximum results (default 10, max 100).
"""
api = ctx.request_context.lifespan_context.api
limit = clamp_limit(limit)
try:
result = await api.search_news(coins=[coin], limit=limit, page=1)
data = result.get("data", [])[:limit]
return make_serializable({
"success": True, "coin": coin, "data": data,
"count": len(data), "total": result.get("total", 0),
})
except Exception as e:
return {"success": False, "error": str(e) or repr(e)}
@mcp.tool()
async def get_news_by_source(engine_type: str, news_type: str, ctx: Context, limit: int = 10) -> dict:
"""Get news articles from a specific source.
Use get_news_sources first to see available engine types and news type codes.
Args:
engine_type: The engine type (e.g. "news", "listing", "onchain", "meme", "market").
news_type: The news source code (e.g. "Bloomberg", "Reuters", "Coindesk").
limit: Maximum results (default 10, max 100).
"""
api = ctx.request_context.lifespan_context.api
limit = clamp_limit(limit)
try:
result = await api.search_news(engine_types={engine_type: [news_type]}, limit=limit, page=1)
data = result.get("data", [])[:limit]
return make_serializable({
"success": True, "engine_type": engine_type, "news_type": news_type, "data": data,
"count": len(data), "total": result.get("total", 0),
})
except Exception as e:
return {"success": False, "error": str(e) or repr(e)}
@mcp.tool()
async def get_news_by_engine(engine_type: str, ctx: Context, limit: int = 10) -> dict:
"""Get news articles filtered by engine type.
Engine types: "news", "listing", "onchain", "meme", "market".
Args:
engine_type: The engine type code.
limit: Maximum results (default 10, max 100).
"""
api = ctx.request_context.lifespan_context.api
limit = clamp_limit(limit)
try:
result = await api.search_news(engine_types={engine_type: []}, limit=limit, page=1)
data = result.get("data", [])[:limit]
return make_serializable({
"success": True, "engine_type": engine_type, "data": data,
"count": len(data), "total": result.get("total", 0),
})
except Exception as e:
return {"success": False, "error": str(e) or repr(e)}
@mcp.tool()
async def search_news_advanced(
ctx: Context,
coins: str = "",
keyword: str = "",
engine_types: str = "",
has_coin: bool = False,
limit: int = 10,
) -> dict:
"""Advanced news search with multiple filters.
Args:
coins: Comma-separated coin symbols (e.g. "BTC,ETH").
keyword: Optional search keyword.
engine_types: Engine type filter in format "type1:cat1,cat2;type2:cat3" (e.g. "news:Bloomberg,Reuters;listing:").
has_coin: If true, only return news that have associated coins.
limit: Maximum results (default 10, max 100).
"""
api = ctx.request_context.lifespan_context.api
limit = clamp_limit(limit)
coin_list = [c.strip() for c in coins.split(",") if c.strip()] if coins else None
# 解析 engine_types 字符串为 dict
engine_types_dict = None
if engine_types:
engine_types_dict = {}
for part in engine_types.split(";"):
if ":" in part:
engine, cats = part.split(":", 1)
engine = engine.strip()
cat_list = [c.strip() for c in cats.split(",") if c.strip()]
engine_types_dict[engine] = cat_list
try:
result = await api.search_news(
coins=coin_list, query=keyword or None,
engine_types=engine_types_dict, has_coin=has_coin,
limit=limit, page=1,
)
data = result.get("data", [])[:limit]
return make_serializable({
"success": True, "data": data,
"count": len(data), "total": result.get("total", 0),
})
except Exception as e:
return {"success": False, "error": str(e) or repr(e)}
@mcp.tool()
async def get_high_score_news(ctx: Context, min_score: int = 70, limit: int = 10) -> dict:
"""Get highly-rated news articles (by AI score), sorted by score descending.
Args:
min_score: Minimum score threshold (default 70).
limit: Maximum results to return (default 10, max 100).
"""
api = ctx.request_context.lifespan_context.api
limit = clamp_limit(limit)
try:
fetch_limit = min(limit * 3, MAX_ROWS)
result = await api.search_news(limit=fetch_limit, page=1)
raw = result.get("data", [])
filtered = [it for it in raw
if (it.get("aiRating") or {}).get("score", 0) >= min_score]
filtered.sort(
key=lambda x: (x.get("aiRating") or {}).get("score", 0),
reverse=True,
)
data = filtered[:limit]
return make_serializable({
"success": True, "min_score": min_score,
"data": data, "count": len(data),
})
except Exception as e:
return {"success": False, "error": str(e) or repr(e)}
@mcp.tool()
async def get_news_by_signal(signal: str, ctx: Context, limit: int = 10) -> dict:
"""Get news filtered by trading signal type.
Args:
signal: The signal type: "long" (bullish), "short" (bearish), or "neutral".
limit: Maximum results (default 10, max 100).
"""
api = ctx.request_context.lifespan_context.api
limit = clamp_limit(limit)
try:
fetch_limit = min(limit * 3, MAX_ROWS)
result = await api.search_news(limit=fetch_limit, page=1)
raw = result.get("data", [])
filtered = [it for it in raw
if (it.get("aiRating") or {}).get("signal") == signal
and (it.get("aiRating") or {}).get("status") == "done"]
data = filtered[:limit]
return make_serializable({
"success": True, "signal": signal,
"data": data, "count": len(data),
})
except Exception as e:
return {"success": False, "error": str(e) or repr(e)}
+68
View File
@@ -0,0 +1,68 @@
"""Real-time news tools — WebSocket subscription for live news updates."""
from mcp.server.fastmcp import Context
from opennews_mcp.app import mcp
from opennews_mcp.config import make_serializable
@mcp.tool()
async def subscribe_latest_news(
ctx: Context,
wait_seconds: int = 10,
max_items: int = 5,
coins: str = "",
engine_types: str = "",
has_coin: bool = False,
) -> dict:
"""Subscribe to real-time news updates via WebSocket.
Connects to the WebSocket feed, subscribes to news with optional filters,
and collects incoming messages for the specified duration.
Args:
wait_seconds: How long to listen for news (default 10, max 30 seconds).
max_items: Maximum news items to collect (default 5, max 20).
coins: Comma-separated coin symbols to filter (e.g. "BTC,ETH").
engine_types: Engine type filter in format "type1:cat1,cat2;type2:cat3".
has_coin: If true, only receive news that have associated coins.
"""
ws = ctx.request_context.lifespan_context.ws
wait_seconds = min(max(1, wait_seconds), 30)
max_items = min(max(1, max_items), 20)
# 解析过滤器参数
coin_list = [c.strip() for c in coins.split(",") if c.strip()] if coins else None
engine_types_dict = None
if engine_types:
engine_types_dict = {}
for part in engine_types.split(";"):
if ":" in part:
engine, cats = part.split(":", 1)
engine = engine.strip()
cat_list = [c.strip() for c in cats.split(",") if c.strip()]
engine_types_dict[engine] = cat_list
try:
sub_result = await ws.subscribe_latest(
engine_types=engine_types_dict,
coins=coin_list,
has_coin=has_coin,
)
items = []
for _ in range(max_items):
msg = await ws.receive_news(timeout=float(wait_seconds))
if msg is None:
break
items.append(msg)
return make_serializable({
"success": True,
"data": items,
"count": len(items),
})
except Exception as e:
return {"success": False, "error": str(e) or repr(e)}
finally:
await ws.close()