From 527dcf672b9798f7def9cb83ef2bb261a09c5646 Mon Sep 17 00:00:00 2001 From: Docker Date: Wed, 26 Aug 2026 17:44:06 +0800 Subject: [PATCH] feat: add qmt_list_apis MCP tool (OpenAPI endpoint discovery for coding agents) --- src/bridge_mcp_server.py | 70 ++++++++++++++++++++++++++++++++++++++++ tests/test_mcp.py | 32 +++++++++++++++--- 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/src/bridge_mcp_server.py b/src/bridge_mcp_server.py index ac2b511..adb1fa9 100644 --- a/src/bridge_mcp_server.py +++ b/src/bridge_mcp_server.py @@ -173,10 +173,80 @@ def _tool_orders(args): def _tool_trades(args): return {"ok": True, "data": get_trade_data("deal")} +# ================= OPENAPI DISCOVERY TOOL ================= +# qmt_list_apis: lets a coding agent inspect which HTTP endpoints the bridge +# exposes. Data source = the deployed openapi.json (same file served by +# GET /openapi.json), so the tool always reflects the current spec. +import os + +def _load_openapi(): + """Load the deployed openapi.json next to this module. Returns dict or None.""" + try: + deploy_dir = os.path.dirname(os.path.abspath(__file__)) + path = os.path.join(deploy_dir, "openapi.json") + if not os.path.exists(path): + return None + with open(path, "rb") as f: + return json.loads(f.read().decode("utf-8")) + except Exception as exc: + _log_err("mcp.load_openapi", exc) + return None + +def _openapi_summary(doc): + """Build a compact endpoint list from the spec (paths + methods + summary).""" + paths = doc.get("paths") or {} + out = [] + for path in sorted(paths.keys()): + item = paths[path] + for method in ("get", "post", "put", "delete"): + op = item.get(method) + if not op: + continue + out.append({ + "path": path, + "method": method.upper(), + "summary": op.get("summary", ""), + }) + return out + +def _tool_list_apis(args): + """List the bridge's HTTP API surface from the deployed OpenAPI spec. + + detail=true returns the full spec (paths/params/examples, ~40KB); + default returns a compact {path, method, summary} list. + """ + doc = _load_openapi() + if doc is None: + raise McpError(-32603, "openapi.json not deployed next to bridge module") + detail = bool(args.get("detail")) + if detail: + return {"ok": True, "title": doc.get("info", {}).get("title"), + "version": doc.get("info", {}).get("version"), + "spec": doc} + return {"ok": True, "title": doc.get("info", {}).get("title"), + "version": doc.get("info", {}).get("version"), + "endpoints": _openapi_summary(doc)} + # ================= TOOL REGISTRY ================= # spec = what tools/list returns (name/description/inputSchema) # fn = handler(args dict) -> plain-JSON result _TOOLS = [ + { + "spec": { + "name": "qmt_list_apis", + "description": "List the bridge HTTP API surface from the deployed OpenAPI spec. " + "Use this FIRST to discover available endpoints before calling other tools. " + "detail=true returns the full spec (~40KB); default returns compact path/method/summary.", + "inputSchema": { + "type": "object", + "properties": { + "detail": {"type": "boolean", + "description": "true = full OpenAPI spec, false (default) = compact endpoint list"}, + }, + }, + }, + "fn": _tool_list_apis, + }, { "spec": { "name": "qmt_kline", diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 869a097..b9666c7 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -96,16 +96,38 @@ check("initialize", st == 200 and r["result"]["protocolVersion"] == "2025-06-18" and r["result"]["capabilities"].get("tools") is not None, str(r)) -# 2. tools/list -> 9 tools +# 2. tools/list -> 10 tools (9 query + qmt_list_apis) st, r = post({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}) tools = r["result"]["tools"] names = [t["name"] for t in tools] -check("tools/list count", len(tools) == 9, str(names)) -for need in ["qmt_kline", "qmt_quote", "qmt_tick", "qmt_instrument", - "qmt_trading_dates", "qmt_positions", "qmt_asset", - "qmt_orders", "qmt_trades"]: +check("tools/list count", len(tools) == 10, str(names)) +for need in ["qmt_list_apis", "qmt_kline", "qmt_quote", "qmt_tick", + "qmt_instrument", "qmt_trading_dates", "qmt_positions", + "qmt_asset", "qmt_orders", "qmt_trades"]: check("tool %s" % need, need in names) +# 2b. tools/call qmt_list_apis (needs openapi.json next to src module) +import shutil, os as _os +SRC = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "..", "src") +SPEC_SRC = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), "..", + "docs", "api_spec", "openapi.json") +SPEC_DST = _os.path.join(SRC, "openapi.json") +if _os.path.exists(SPEC_SRC): + shutil.copy(SPEC_SRC, SPEC_DST) +st, r = post({"jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": {"name": "qmt_list_apis", "arguments": {}}}) +text = json.loads(r["result"]["content"][0]["text"]) +check("qmt_list_apis compact", text["ok"] is True + and len(text["endpoints"]) == 13, str(text)[:150]) +st, r = post({"jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": {"name": "qmt_list_apis", "arguments": {"detail": True}}}) +text = json.loads(r["result"]["content"][0]["text"]) +check("qmt_list_apis detail", text["ok"] is True + and "paths" in text["spec"] and "/mcp" in text["spec"]["paths"], + str(text)[:150]) +if _os.path.exists(SPEC_DST): + _os.remove(SPEC_DST) + # 3. tools/call qmt_kline st, r = post({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "qmt_kline",