diff --git a/docs/features.md b/docs/features.md index 8afc0f42..7c8da4ea 100644 --- a/docs/features.md +++ b/docs/features.md @@ -287,6 +287,18 @@ curl "http://localhost:8398/api/openapi.json?tier=a" 返回按网关规则表过滤后的 OpenAPI 3 规范(`x-tier: a`)—— 哪些路径对外开放、需要什么 scope,**规则表是唯一契约源**,生成代码 / Postman 导入即用。开放面有**契约快照测试**守护(`test_openapi_contract.py`):增删开放端点必须显式更新快照,CI 会拦下无意识的契约变更。 +### MCP 接入(AI 客户端) + +`mcp-server/` 提供 **MCP (Model Context Protocol)** 服务器,把开放接口包装成 AI 可调用工具 — Claude / ZCode / Cursor 等客户端可直接查行情、看市场环境、读策略与告警、触发回测: + +``` +AI 客户端 ⇄ MCP stdio (薄桥) ⇄ HTTP + Bearer Token ⇄ 开放网关 +``` + +- 工具按 Token 的 scope 暴露(12 个:搜索/日K/指数/总览/环境/策略/告警/扩展表读/写/回测),**权限裁决仍在网关** — 桥不持有业务逻辑,面板升级自动受益; +- 模拟盘交易类端点刻意未包装(不建议把下单交给 AI); +- 配置方法与工具清单见 [mcp-server/README](../mcp-server/README.md),可运行冒烟测试 `smoke_test.py` 验证连通。 + ### 桌面客户端版本清单 发布流水线会在每个 Release 附带 `latest.json`(版本号、三平台下载地址、sha256),设置 → 系统设置 → 检查更新即基于它(优先 GitHub API)提示新版本。 diff --git a/mcp-server/README.md b/mcp-server/README.md new file mode 100644 index 00000000..12a6c489 --- /dev/null +++ b/mcp-server/README.md @@ -0,0 +1,68 @@ +# tick-stock-panel MCP 服务器 + +把面板的开放接口包装成 **MCP (Model Context Protocol)** 工具, 让 Claude / ZCode / Cursor 等 AI 客户端直接查询行情、市场环境、策略与告警, 触发回测。 + +``` +AI 客户端 ⇄ MCP stdio (本服务器) ⇄ HTTP + Bearer Token ⇄ 面板开放网关 +``` + +权限、限流、契约全部复用面板的开放接口层 — 本服务器只是一层薄桥, 面板侧的 Token scope 是最终权威。 + +## 准备 + +1. 面板 → **设置 → 开放接口** → 新建 Token, 勾选所需 scope +2. 记下 `tsp_` 开头的明文 (只显示一次) + +## 配置到 MCP 客户端 + +以 Claude Desktop (`claude_desktop_config.json`) / 任何支持 stdio MCP 的客户端为例: + +```json +{ + "mcpServers": { + "tick-stock-panel": { + "command": "uv", + "args": ["--directory", "E:/tick-stock-panel/mcp-server", "run", "tsp-mcp-server"], + "env": { + "TSP_BASE": "http://127.0.0.1:3018", + "TSP_TOKEN": "tsp_xxxx", + "TSP_SCOPES": "read:market,read:ext,read:analysis" + } + } + } +} +``` + +- `TSP_BASE`: 面板地址 (默认 `http://127.0.0.1:3018`) +- `TSP_TOKEN`: API Token +- `TSP_SCOPES`: 逗号分隔, 决定暴露哪些工具 (默认只读三档)。**真正的权限裁决在面板网关** — 声明了没授权的 scope, 调用会得到 403, AI 能看到错误并停止 + +手动验证: + +```bash +cd mcp-server +TSP_TOKEN=tsp_xxx uv run tsp-mcp-server # stdio JSON-RPC, 供客户端拉起 +``` + +## 工具清单 (按 scope) + +| scope | 工具 | 说明 | +| :--- | :--- | :--- | +| `read:market` | `search_instrument` | 代码/名称/拼音搜标的 | +| | `get_kline` | 标的日K (前复权) | +| | `get_index_kline` | 指数日K | +| | `get_market_overview` | 涨跌家数/成交额/涨停连板/情绪 | +| `read:analysis` | `get_regime_state` | 最新市场环境阶段 | +| | `list_strategies` / `get_strategy_detail` | 策略清单与定义 | +| | `get_recent_alerts` | 最近告警 | +| `read:ext` | `list_ext_tables` / `query_ext_table` | 自有数据表查询 | +| `write:ext` | `write_ext_rows` | 写入扩展表 (进入策略数据面, 慎授) | +| `run:backtest` | `run_backtest` | 策略回测 (纯 Polars 引擎) | + +未包含: 模拟盘交易类端点 — 不建议把下单能力交给 AI, 需要时可自行在 `server.py` 里按同样模式补。 + +## 设计说明 + +- **薄桥原则**: 所有权限/限流/契约由面板网关裁决, 本服务器不持有任何业务逻辑, 面板升级自动受益 +- 工具返回原文 JSON, 错误以可读文本返回 — LLM 可据此自行纠正参数 +- 更底层的 REST 契约与示例见 [examples/open-api](../examples/open-api/README.md) 与 [docs/features.md → 开放接口](../docs/features.md) diff --git a/mcp-server/pyproject.toml b/mcp-server/pyproject.toml new file mode 100644 index 00000000..79d5992d --- /dev/null +++ b/mcp-server/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "tsp-mcp-server" +version = "0.3.2" +description = "tick-stock-panel MCP 服务器 — 把面板开放接口暴露为 AI 可调用工具 (Model Context Protocol)" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "mcp>=2.0", + "httpx>=0.27", +] + +[project.scripts] +tsp-mcp-server = "tsp_mcp_server.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/tsp_mcp_server"] diff --git a/mcp-server/smoke_test.py b/mcp-server/smoke_test.py new file mode 100644 index 00000000..f04a8f16 --- /dev/null +++ b/mcp-server/smoke_test.py @@ -0,0 +1,91 @@ +"""MCP 服务器冒烟测试 — 子进程拉起, 走完整 JSON-RPC: 握手/工具清单/实际调用。 + +用法: TSP_TOKEN=tsp_xxx [TSP_BASE=...] python smoke_test.py +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def main() -> None: + proc = subprocess.Popen( + [sys.executable, "-m", "tsp_mcp_server.server"], + cwd=os.path.join(BASE_DIR, "src"), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + env=os.environ, + ) + next_id = 0 + + def send(method: str, params: dict | None = None, *, notify: bool = False) -> dict | None: + nonlocal next_id + msg: dict = {"jsonrpc": "2.0", "method": method} + if params is not None: + msg["params"] = params + if not notify: + next_id += 1 + msg["id"] = next_id + proc.stdin.write(json.dumps(msg) + "\n") + proc.stdin.flush() + if notify: + return None + # 读到带对应 id 的响应 (跳过日志/通知行) + while True: + line = proc.stdout.readline() + if not line: + err = proc.stderr.read() + raise RuntimeError(f"MCP server 提前退出: {err[-500:]}") + try: + resp = json.loads(line) + except json.JSONDecodeError: + continue + if resp.get("id") == next_id: + if "error" in resp: + raise RuntimeError(f"JSON-RPC 错误: {resp['error']}") + return resp["result"] + + try: + # 1. 握手 + init = send("initialize", { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "smoke", "version": "0"}, + }) + print("server:", init["serverInfo"]["name"]) + send("notifications/initialized", notify=True) + + # 2. 工具清单 + tools = send("tools/list")["tools"] + print(f"tools ({len(tools)}):", ", ".join(sorted(t["name"] for t in tools))) + names = {t["name"] for t in tools} + + # 3. 实际调用: 搜索 + 总览 + 环境 + calls = [ + ("search_instrument", {"query": "600519", "limit": 2}, "贵州茅台"), + ("get_market_overview", {}, "as_of"), + ("get_regime_state", {}, "row"), + ] + for name, args, expect in calls: + if name not in names: + print(f"skip {name} (scope 未开放)") + continue + r = send("tools/call", {"name": name, "arguments": args}) + text = r["content"][0]["text"] + assert expect in text, f"{name} 返回异常: {text[:200]}" + print(f"{name}: OK -> {text[:100]}") + print("SMOKE PASS") + finally: + proc.terminate() + proc.wait(timeout=5) + + +if __name__ == "__main__": + main() diff --git a/mcp-server/src/tsp_mcp_server/__init__.py b/mcp-server/src/tsp_mcp_server/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mcp-server/src/tsp_mcp_server/server.py b/mcp-server/src/tsp_mcp_server/server.py new file mode 100644 index 00000000..fc10ae0f --- /dev/null +++ b/mcp-server/src/tsp_mcp_server/server.py @@ -0,0 +1,162 @@ +"""tick-stock-panel MCP 服务器 — 开放接口的 AI 工具桥。 + +架构 (docs/features.md → 开放接口): + AI 客户端 (Claude/ZCode/Cursor…) ↔ MCP stdio (JSON-RPC) ↔ 本服务器 + ↔ HTTP + Bearer Token ↔ 面板开放网关 (scope 校验 + 限流) + +工具按 TSP_SCOPES 声明暴露 (逗号分隔, 默认 read:market,read:ext,read:analysis); +声明只是"展示哪些工具", 真正的权限裁决在面板网关 — 声明错了只会得到 403。 +""" +from __future__ import annotations + +import json +import os + +import httpx +from mcp.server.mcpserver import MCPServer + +BASE = os.environ.get("TSP_BASE", "http://127.0.0.1:3018").rstrip("/") +TOKEN = os.environ.get("TSP_TOKEN", "") +SCOPES = {s.strip() for s in os.environ.get( + "TSP_SCOPES", "read:market,read:ext,read:analysis", +).split(",") if s.strip()} + +mcp = MCPServer( + "tick-stock-panel", + instructions=( + "A股行情、策略回测与监控面板。标的代码格式 600519.SH / 000001.SZ / 510300.SH;" + "指数如 000001.SH(上证)。涉及交易/写数据的工具需面板侧授予相应权限。" + ), +) + +_client = httpx.Client( + base_url=BASE, + headers={"Authorization": f"Bearer {TOKEN}"}, + timeout=60.0, +) + + +def _call(method: str, path: str, **kw) -> str: + """调面板开放接口; 非 2xx 返回可读错误 (LLM 能据此纠正参数)。""" + if not TOKEN: + return "错误: 未设置 TSP_TOKEN (面板 → 设置 → 开放接口 → 新建 Token)" + try: + r = _client.request(method, path, **kw) + r.raise_for_status() + return r.text + except httpx.HTTPStatusError as e: + try: + detail = e.response.json().get("detail", e.response.text[:200]) + except Exception: # noqa: BLE001 + detail = e.response.text[:200] + return f"HTTP {e.response.status_code}: {detail}" + except httpx.HTTPError as e: + return f"连接失败 ({BASE}): {e}" + + +def _has(scope: str) -> bool: + return scope in SCOPES + + +# ── read:market 行情 ────────────────────────────────── + +if _has("read:market"): + + @mcp.tool() + def search_instrument(query: str, limit: int = 8) -> str: + """按代码/名称/拼音首字母搜索标的, 返回 symbol+名称 (后续工具的 symbol 从这里来)。""" + return _call("GET", "/api/kline/instruments/search", params={"q": query, "limit": limit}) + + @mcp.tool() + def get_kline(symbol: str, limit: int = 30) -> str: + """标的日K (前复权): date/open/high/low/close/volume/amount, 最新在后。""" + return _call("GET", "/api/kline/daily", params={"symbol": symbol, "limit": limit}) + + @mcp.tool() + def get_index_kline(symbol: str = "000001.SH", days: int = 60) -> str: + """指数日K, 如 000001.SH 上证指数 / 399006.SZ 创业板指。""" + return _call("GET", "/api/index/daily", params={"symbol": symbol, "days": days}) + + @mcp.tool() + def get_market_overview() -> str: + """市场总览: 快照日期、涨跌家数、成交额、涨停/连板统计、情绪指标、行情源状态。""" + return _call("GET", "/api/overview/market") + + +# ── read:analysis 策略/环境/告警 ─────────────────────── + +if _has("read:analysis"): + + @mcp.tool() + def get_regime_state() -> str: + """最新市场环境 (情绪周期阶段/环境分), 大盘择时判断的一手口径。""" + return _call("GET", "/api/regime/latest") + + @mcp.tool() + def list_strategies() -> str: + """策略清单 (内置+自定义): id/名称/标签/描述 — run_backtest 的 strategy_id 从这里来。""" + return _call("GET", "/api/strategies") + + @mcp.tool() + def get_strategy_detail(strategy_id: str) -> str: + """单个策略的完整定义 (信号条件/参数/评分)。""" + return _call("GET", f"/api/strategies/{strategy_id}") + + @mcp.tool() + def get_recent_alerts(limit: int = 20) -> str: + """最近触发的监控告警 (信号/规则/异动, 时间倒序, 最多 7 天)。""" + return _call("GET", "/api/alerts", params={"limit": limit}) + + +# ── read:ext / write:ext 扩展数据 ───────────────────── + +if _has("read:ext"): + + @mcp.tool() + def list_ext_tables() -> str: + """扩展表清单 (用户接入的自有数据表): id/名称/模式/字段。""" + return _call("GET", "/api/ext-data") + + @mcp.tool() + def query_ext_table(table: str, filter: str | None = None, limit: int = 20) -> str: + """查询扩展表行数据。filter 形如 "字段:值1|值2" (可重复传多个条件参数)。""" + params: dict = {"limit": limit} + if filter: + params["filter"] = filter + return _call("GET", f"/api/ext-data/{table}/rows", params=params) + +if _has("write:ext"): + + @mcp.tool() + def write_ext_rows(table: str, rows: list[dict]) -> str: + """向扩展表写入行数据 (会进入策略/回测数据面)。rows 每行须含表定义的必填字段。""" + return _call("POST", f"/api/ext-data/{table}/ingest", json={"rows": rows}) + + +# ── run:backtest 回测 ───────────────────────────────── + +if _has("run:backtest"): + + @mcp.tool() + def run_backtest(strategy_id: str, symbols: list[str], days: int = 90) -> str: + """对指定标的跑策略回测 (纯 Polars 引擎), 返回指标/回合/净值。窗口别太大, 90~365 天为宜。""" + from datetime import date, timedelta + + end = date.today() + start = end - timedelta(days=days) + return _call("POST", "/api/backtest/strategy/run", json={ + "strategy_id": strategy_id, + "symbols": symbols, + "start": start.isoformat(), + "end": end.isoformat(), + }) + + +def main() -> None: + if not TOKEN: + raise SystemExit("未设置 TSP_TOKEN 环境变量 — 面板 → 设置 → 开放接口 → 新建 Token") + mcp.run() # stdio 传输, 由 MCP 客户端拉起 + + +if __name__ == "__main__": + main()