8f016f25df
- 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
351 lines
13 KiB
Python
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() |