Files
market_sync/app/mcp_server.py
T
gao 8f016f25df chore: baseline — ORM migration + systemd deploy + MCP server + daily check
- ORM migration: models/ops 重构
- deploy: 标准化 systemd timer/service 部署体系
- MCP server: 5 个只读工具(任务/状态/数据集)
- daily check: 数据一致性巡检
- watch: task_watch 后台监控
- kline_daily: 麦蕊优先,时间门禁15:00
- 删除: task_industry_sector, task_sector_features
2026-07-21 16:37:00 +08:00

351 lines
13 KiB
Python

"""market_sync MCP Server。
提供 5 类工具(tool),让 LLM 能查 market_sync 的同步情况:
1. list_sync_tasks — 列出所有同步任务
2. get_task_status — 查单个任务的状态机 + 最近 run
3. list_datasets — 列出所有数据集(含数据规模)
4. get_dataset_info — 单个数据集的详细范围(日期范围/数据量/列)
5. get_today_sync_summary — 今日同步汇总
传输: stdio(本地启动 Claude Desktop / Claude Code 直连)。
启动:
python -m app.mcp_server
# 或作为 MCP server 注册到客户端
架构:
- MCP 协议层: mcp.server.Server + stdio_server
- 业务层: 复用 app.core.db.ops + app.core.sync.registry 的 helper
- 不直接操作 PG,通过 ORM session(一致的事务/连接管理)
"""
from __future__ import annotations
import asyncio
import json
import sys
from datetime import datetime
from pathlib import Path
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(_PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(_PROJECT_ROOT))
# ── MCP server bootstrap ────────────────────────────────────────────
from mcp.server import Server
from mcp.server.sse import SseServerTransport
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
server = Server("market_sync")
# ── helpers ─────────────────────────────────────────────────────────
def _serialize(obj):
"""JSON-friendly: datetime/date/Decimal → str."""
if isinstance(obj, dict):
return {k: _serialize(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_serialize(v) for v in obj]
if isinstance(obj, (datetime,)):
return obj.isoformat()
try:
# handle date
import datetime as _dt
if isinstance(obj, _dt.date):
return obj.isoformat()
except Exception:
pass
return obj
def _ok(data) -> list[TextContent]:
"""Standard success response: JSON dump."""
return [TextContent(
type="text",
text=json.dumps(_serialize(data), ensure_ascii=False, indent=2, default=str),
)]
def _err(msg: str) -> list[TextContent]:
return [TextContent(type="text", text=f"{msg}")]
# ── Tool implementations ─────────────────────────────────────────────
def list_sync_tasks() -> list[dict]:
from app.tasks import TASKS
from app.core.db import ops as db_ops
from app.core.sync import get_registry_status
registry = {r["dataset_id"]: r for r in get_registry_status()}
history = db_ops.list_sync_history(days=7, limit=1000)
last_by_task: dict[str, dict] = {}
for h in history:
if h["dataset_id"] not in last_by_task:
last_by_task[h["dataset_id"]] = h
out = []
for ds_id, cls in TASKS.items():
reg = registry.get(ds_id, {})
last = last_by_task.get(ds_id)
out.append({
"dataset_id": ds_id,
"name": reg.get("name", ""),
"description": reg.get("description", ""),
"source": reg.get("source", ""),
"sort_order": reg.get("sort_order", 0),
"current_status": reg.get("status", "unknown"),
"last_run_at": (last or {}).get("started_at"),
"last_run_status": (last or {}).get("status"),
"last_run_rows": (last or {}).get("rows_written"),
"history_count_7d": sum(1 for h in history if h["dataset_id"] == ds_id),
})
return sorted(out, key=lambda x: x["sort_order"])
def get_task_status(dataset_id: str, days: int = 7) -> dict:
from app.core.db import ops as db_ops
from app.core.sync import get_registry_status
reg = next((r for r in get_registry_status() if r["dataset_id"] == dataset_id), None)
if not reg:
return {"error": f"unknown dataset_id: {dataset_id}"}
history = db_ops.list_sync_history(dataset_id=dataset_id, days=days, limit=50)
return {
"dataset_id": dataset_id,
"current_state": reg,
"recent_runs": history,
}
def list_datasets() -> list[dict]:
"""每个数据集的当前规模(行数 + 日期范围 + 来源)。"""
from sqlalchemy import func, select
from app.core.db.models import (
KlineStock, KlineIndex, Kline5Min, Moneyflow, Share, TickTrade,
MarketRegimeDaily,
LonghubangDaily, LonghubangSeat, Stock,
NodeCategory, Node, StockNodeMap, KlineStockMADaily,
KlineStockMACDDaily, KlineStockKDJDaily, KlineStockBOLLDaily,
)
from app.core.db.orm import SessionLocal
targets = [
("stocks", Stock),
("kline_stock", KlineStock),
("kline_index", KlineIndex),
("kline_5min", Kline5Min),
("kline_stock_ma_daily", KlineStockMADaily),
("moneyflow", Moneyflow),
("share", Share),
("tick_trade", TickTrade),
("market_regime_daily", MarketRegimeDaily),
("longhubang_daily", LonghubangDaily),
("longhubang_seat", LonghubangSeat),
("node_categories", NodeCategory),
("nodes", Node),
("stock_node_map", StockNodeMap),
("kline_stock_macd_daily", KlineStockMACDDaily),
("kline_stock_kdj_daily", KlineStockKDJDaily),
("kline_stock_boll_daily", KlineStockBOLLDaily),
]
out = []
with SessionLocal() as s:
for name, model in targets:
try:
count = int(s.execute(select(func.count()).select_from(model)).scalar() or 0)
dr = None
if hasattr(model, "trade_date"):
mn = s.execute(select(func.min(model.trade_date))).scalar()
mx = s.execute(select(func.max(model.trade_date))).scalar()
dr = {"min": str(mn), "max": str(mx)} if mn else None
out.append({"table": name, "rows": count, "date_range": dr})
except Exception as e:
out.append({"table": name, "rows": -1, "error": str(e)[:100]})
return out
def get_dataset_info(table_name: str) -> dict:
"""单表 schema + 数据范围 + 抽样行。"""
from sqlalchemy import inspect
from app.core.db.orm import SessionLocal
# 表名白名单(避免任意 SQL 注入)
allowed = {
"stocks", "kline_stock", "kline_index", "kline_5min",
"kline_stock_ma_daily", "moneyflow", "share", "tick_trade",
"market_regime_daily",
"longhubang_daily", "longhubang_seat",
"node_categories", "nodes", "stock_node_map",
"kline_stock_macd_daily", "kline_stock_kdj_daily", "kline_stock_boll_daily",
"dataset_registry", "sync_history", "config",
}
if table_name not in allowed:
return {"error": f"unknown table: {table_name}", "allowed": sorted(allowed)}
with SessionLocal() as s:
insp = inspect(s.get_bind())
cols = [{"name": c["name"], "type": str(c["type"]), "nullable": c.get("nullable", True)}
for c in insp.get_columns(table_name, schema="market_data")]
pk = insp.get_pk_constraint(table_name, schema="market_data")
idx = [i["name"] for i in insp.get_indexes(table_name, schema="market_data")]
sample = s.execute(
__import__("sqlalchemy").text(
f'SELECT * FROM market_data."{table_name}" LIMIT 3'
)
).mappings().all()
return {
"table": table_name,
"schema": "market_data",
"primary_key": pk.get("constrained_columns", []),
"indexes": idx,
"columns": cols,
"sample_rows": [dict(r) for r in sample],
}
def get_today_sync_summary() -> dict:
from datetime import date
from app.core.db import ops as db_ops
today = date.today().isoformat()
summary_rows = db_ops.daily_sync_summary(days=1)
today_summary = next((r for r in summary_rows if r["date"] == today), None) or {
"date": today, "ok": 0, "warning": 0, "error": 0, "blocked": 0, "total": 0,
}
history = db_ops.list_sync_history(days=1, limit=500)
return {
"date": today,
"summary": today_summary,
"tasks_today": history,
}
# ── MCP tool 注册 ──────────────────────────────────────────────────
@server.list_tools()
async def list_tools():
return [
Tool(
name="list_sync_tasks",
description="列出 market_sync 项目的所有同步任务(dataset_id + 名称 + 当前状态 + 最近 7 天运行统计)。",
inputSchema={"type": "object", "properties": {}},
),
Tool(
name="get_task_status",
description="查询单个同步任务的详细状态机 + 最近 N 天所有 run 历史。",
inputSchema={
"type": "object",
"properties": {
"dataset_id": {"type": "string", "description": "例如 kline_daily / moneyflow"},
"days": {"type": "integer", "description": "查最近 N 天, 默认 7", "default": 7},
},
"required": ["dataset_id"],
},
),
Tool(
name="list_datasets",
description="列出所有 PG 数据集(含 market_data schema 下 17 张业务表)的行数 + 日期范围。",
inputSchema={"type": "object", "properties": {}},
),
Tool(
name="get_dataset_info",
description="查询单个数据集的表结构(列名 + 类型 + 主键 + 索引)+ 3 行抽样数据。",
inputSchema={
"type": "object",
"properties": {
"table_name": {"type": "string", "description": "如 kline_stock / moneyflow / tick_trade"},
},
"required": ["table_name"],
},
),
Tool(
name="get_today_sync_summary",
description="今日同步情况汇总(OK / Warning / Error / Blocked 计数 + 每个任务的最新 run)。",
inputSchema={"type": "object", "properties": {}},
),
]
@server.call_tool()
async def call_tool(name: str, arguments: dict):
try:
if name == "list_sync_tasks":
return _ok(list_sync_tasks())
elif name == "get_task_status":
ds_id = arguments.get("dataset_id", "")
days = int(arguments.get("days", 7))
return _ok(get_task_status(ds_id, days))
elif name == "list_datasets":
return _ok(list_datasets())
elif name == "get_dataset_info":
return _ok(get_dataset_info(arguments.get("table_name", "")))
elif name == "get_today_sync_summary":
return _ok(get_today_sync_summary())
else:
return _err(f"unknown tool: {name}")
except Exception as e:
import traceback
return _err(f"{type(e).__name__}: {e}\n{traceback.format_exc(limit=3)}")
# ── main ────────────────────────────────────────────────────────────
async def main_sse(host: str = "127.0.0.1", port: int = 8101):
"""启动 MCP 的 SSE (HTTP) 服务,供持久运行。
客户端连接: http://{host}:{port}/sse
"""
sse_transport = SseServerTransport("/messages")
async def app(scope, receive, send):
if scope["type"] != "http":
return
path = scope.get("path", "")
if path == "/sse":
async with sse_transport.connect_sse(scope, receive, send) as (read, write):
await server.run(read, write, server.create_initialization_options())
elif path == "/messages" and scope.get("method") == "POST":
await sse_transport.handle_post_message(scope, receive, send)
import uvicorn
from app.core.utils.logging import get_logger
logger = get_logger("mcp")
logger.info(f"MCP SSE server listening on http://{host}:{port}/sse")
config = uvicorn.Config(app, host=host, port=port, log_level="info")
srv = uvicorn.Server(config)
await srv.serve()
def main():
from app.core.datasource.registry import build_default_registry
build_default_registry()
import sys
if "--sse" in sys.argv:
host = "127.0.0.1"
port = 8101
for i, arg in enumerate(sys.argv):
if arg == "--host" and i + 1 < len(sys.argv):
host = sys.argv[i + 1]
if arg == "--port" and i + 1 < len(sys.argv):
port = int(sys.argv[i + 1])
asyncio.run(main_sse(host, port))
else:
asyncio.run(_stdio_main())
async def _stdio_main():
async with stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
server.create_initialization_options(),
)
if __name__ == "__main__":
main()