# -*- coding: utf-8 -*- """ 通风模型算法 MCP 接口全面测试脚本 测试 server: vent-model-tools @ http://39.97.59.228:8071/mcp """ import asyncio import json import time import os from datetime import datetime from fastmcp import Client MCP_URL = "http://39.97.59.228:8071/mcp" MODEL_ID = "2012326636757958658" # ── 测试用例定义 ── # 按文档中的 15 个接口定义 TEST_CASES = [ # ═══ 故障诊断类 ═══ { "id": "TC-01", "category": "故障诊断", "tool": "check_model_connect_status", "params": {"model_id": MODEL_ID}, "desc": "模型网络连通检查", }, { "id": "TC-02", "category": "故障诊断", "tool": "check_model_one_dir_cycle", "params": {"model_id": MODEL_ID}, "desc": "模型循环风路检查", }, { "id": "TC-03", "category": "故障诊断", "tool": "check_model_one_dir_node", "params": {"model_id": MODEL_ID}, "desc": "模型单向节点检查", }, { "id": "TC-04", "category": "故障诊断", "tool": "check_model_diagonal_structure", "params": {"model_id": MODEL_ID}, "desc": "模型角联结构诊断(计算重,耗时较长)", }, { "id": "TC-05", "category": "故障诊断", "tool": "get_model_fault_diagnosis", "params": {"model_id": MODEL_ID, "include_diagonal": False}, "desc": "模型故障诊断(聚合,不含角联)", }, # ═══ 避灾路线类 ═══ { "id": "TC-06", "category": "避灾路线", "tool": "get_escape_path", "params": {"model_id": MODEL_ID, "fire_tun_id": "4", "person_tun_id": "6"}, "desc": "避灾路线模拟", "note": "使用硬编码隧道ID 4/6,实际应取自 get_out_shafts", }, { "id": "TC-07", "category": "避灾路线", "tool": "get_escape_path_each_exit", "params": {"model_id": MODEL_ID, "fire_tun_id": "4", "person_tun_id": "6", "co_per": 2000.0, "during_time": 30.0}, "desc": "避灾路线模拟(各出口,含CO参数)", }, # ═══ 关键阻力类 ═══ { "id": "TC-08", "category": "关键阻力", "tool": "get_out_shafts", "params": {"model_id": MODEL_ID}, "desc": "获取回风井巷道ID列表", }, { "id": "TC-09", "category": "关键阻力", "tool": "get_in_shafts", "params": {"model_id": MODEL_ID}, "desc": "获取进风井巷道ID列表", }, # get_max_resistance_path - need node_id, will be filled dynamically { "id": "TC-10", "category": "关键阻力", "tool": "get_max_resistance_path", "params": {"model_id": MODEL_ID, "node_id": None}, # filled dynamically "desc": "最大阻力路线", "dynamic": True, }, { "id": "TC-11", "category": "关键阻力", "tool": "get_three_area_distribution", "params": {"model_id": MODEL_ID}, "desc": "三区阻力分布", }, { "id": "TC-12", "category": "关键阻力", "tool": "get_key_path_decision", "params": {"model_id": MODEL_ID}, "desc": "关键路径决策", }, # ═══ 压能/解算类 ═══ # get_path_press_power - need node IDs, will be filled dynamically { "id": "TC-13", "category": "压能/解算", "tool": "get_path_press_power", "params": {"model_id": MODEL_ID, "id_from": None, "id_to": None}, # filled dynamically "desc": "节点压能图", "dynamic": True, }, { "id": "TC-14", "category": "压能/解算", "tool": "net_cal", "params": {"model_id": MODEL_ID}, "desc": "网络解算(耗时较长)", }, { "id": "TC-15", "category": "压能/解算", "tool": "net_cal_for_plan", "params": {"model_id": MODEL_ID, "plan": "{}"}, "desc": "方案模拟解算", "note": "plan 参数不能为空字符串,使用空JSON对象作为最小有效方案", }, ] def safe_parse_json(text: str) -> dict: """安全解析 JSON,支持多层嵌套""" try: return json.loads(text) if isinstance(text, str) else text except (json.JSONDecodeError, TypeError): return {"raw": str(text)[:2000]} def extract_deep_result(data: dict) -> dict: """尝试提取深层 result""" # 尝试多层解包 for _ in range(5): if isinstance(data, dict): if "result" in data and isinstance(data["result"], str): try: data = json.loads(data["result"]) continue except (json.JSONDecodeError, TypeError): pass if "result" in data and isinstance(data["result"], dict): data = data["result"] continue break return data def count_result_size(data) -> str: """估算结果大小""" s = json.dumps(data, ensure_ascii=False, default=str) size = len(s) if size < 1024: return f"{size} B" elif size < 1024 * 1024: return f"{size / 1024:.1f} KB" else: return f"{size / (1024 * 1024):.1f} MB" def summarize_structure(data, depth=0) -> str: """概括数据结构""" if depth > 5: return "..." if isinstance(data, dict): keys = list(data.keys()) if len(keys) <= 8: parts = [] for k in keys: v = data[k] if isinstance(v, (dict, list)): parts.append(f"{k}: {summarize_structure(v, depth + 1)}") elif isinstance(v, str) and len(v) > 100: parts.append(f"{k}: str({len(v)})") else: parts.append(f"{k}: {type(v).__name__}") return "{" + ", ".join(parts[:10]) + ("..." if len(parts) > 10 else "") + "}" else: return f"{{...{len(keys)} keys...}}" elif isinstance(data, list): if len(data) == 0: return "[]" return f"[{len(data)} items, first: {summarize_structure(data[0], depth + 1)}]" elif isinstance(data, str): return f'str({len(data)})' else: return type(data).__name__ async def call_single_tool(client: Client, case: dict) -> dict: """调用单个工具并返回统一格式结果""" result = { "id": case["id"], "tool": case["tool"], "category": case["category"], "desc": case["desc"], "params": {k: v for k, v in case["params"].items() if v is not None}, "timestamp": datetime.now().isoformat(), } start = time.perf_counter() try: raw = await client.call_tool(case["tool"], result["params"]) elapsed = time.perf_counter() - start # 提取文本内容 if raw.content and len(raw.content) > 0: text = raw.content[0].text data = safe_parse_json(text) else: text = "" data = {} result.update({ "success": True, "elapsed_ms": round(elapsed * 1000, 2), "has_content": len(raw.content) > 0, "content_len": len(text), "size": count_result_size(data), "structure": summarize_structure(data), "data_preview": json.dumps(data, ensure_ascii=False, default=str)[:500], "deep_success": None, # will check below }) # 检查深层 success 字段 # 注意: bSucced=0 对于故障检测类接口表示"未发现故障"(正常), bSucced=1 表示"检测到故障" # 对于计算类接口, bSucced=1 表示计算成功, bSucced=0 表示计算失败 deep = extract_deep_result(data) if isinstance(deep, dict): # 优先检查 outer success + code 200 outer_ok = data.get("success") and data.get("code") == 200 # 检查 inner result 是否有 bSucced 字段 (1=成功/发现, 0=失败/未发现) has_bSucced = "bSucced" in deep bSucced_val = deep.get("bSucced") # 如果有 error 字段且非空,标记业务异常 has_error = bool(deep.get("error") or data.get("error")) if has_error: result["deep_success"] = False elif outer_ok: result["deep_success"] = True elif has_bSucced: # bSucced 存在时,都视为业务层面正常(0或1都有意义) result["deep_success"] = True else: result["deep_success"] = data.get("code") == 200 except Exception as e: elapsed = time.perf_counter() - start result.update({ "success": False, "elapsed_ms": round(elapsed * 1000, 2), "error": str(e), "error_type": type(e).__name__, }) return result async def discover_node_and_tunnel_ids(client: Client) -> dict: """发现可用的 node_id 和 tunnel_id,用于后续动态测试""" discovered = {"node_ids": [], "out_shaft_tuns": [], "in_shaft_tuns": []} # 获取回风井 try: r = await client.call_tool("get_out_shafts", {"model_id": MODEL_ID}) text = r.content[0].text data = safe_parse_json(text) deep = extract_deep_result(data) # 尝试找到隧道ID列表 if isinstance(deep, dict): for key in ["outShafts", "tunIds", "ids", "data"]: if key in deep and isinstance(deep[key], list): discovered["out_shaft_tuns"] = deep[key][:5] break discovered["out_shafts_raw"] = summarize_structure(data) except Exception as e: discovered["out_shafts_error"] = str(e) # 获取进风井 try: r = await client.call_tool("get_in_shafts", {"model_id": MODEL_ID}) text = r.content[0].text data = safe_parse_json(text) deep = extract_deep_result(data) if isinstance(deep, dict): for key in ["inShafts", "tunIds", "ids", "data"]: if key in deep and isinstance(deep[key], list): discovered["in_shaft_tuns"] = deep[key][:5] break discovered["in_shafts_raw"] = summarize_structure(data) except Exception as e: discovered["in_shafts_error"] = str(e) return discovered async def main(): print("=" * 80) print(" 通风模型算法 MCP 接口测试") print(f" Server: {MCP_URL}") print(f" Model: {MODEL_ID}") print(f" 时间: {datetime.now().isoformat()}") print("=" * 80) print() client = Client(MCP_URL) results = [] async with client: # ── 第一阶段:发现动态参数 ── print("🔍 第一阶段:发现动态参数(回风井/进风井/节点ID)...") discovered = await discover_node_and_tunnel_ids(client) print(f" 回风井: {json.dumps(discovered.get('out_shaft_tuns', []), ensure_ascii=False)}") print(f" 进风井: {json.dumps(discovered.get('in_shaft_tuns', []), ensure_ascii=False)}") # 尝试用已知隧道ID获取更多节点信息(需要调用 get_tun_list_by_modelid 或 net_cal) node_id_from = None node_id_to = None node_id_max_res = None # 先尝试从回风井结果中提取节点ID if discovered.get("out_shaft_tuns"): # 假设可以从 net_cal 结果中获取节点信息 pass # 使用固定节点ID(文档中的示例值) node_id_max_res = "3958" node_id_from = "2" node_id_to = "3958" # ── 更新动态测试用例 ── for case in TEST_CASES: if case["tool"] == "get_max_resistance_path" and case.get("dynamic"): case["params"]["node_id"] = node_id_max_res print(f"\n 📍 TC-10 使用 node_id={node_id_max_res}") elif case["tool"] == "get_path_press_power" and case.get("dynamic"): case["params"]["id_from"] = node_id_from case["params"]["id_to"] = node_id_to print(f" 📍 TC-13 使用 id_from={node_id_from}, id_to={node_id_to}") # ── 第二阶段:逐个测试 ── print(f"\n{'=' * 80}") print(f" 第二阶段:执行 {len(TEST_CASES)} 个测试用例") print(f"{'=' * 80}\n") for i, case in enumerate(TEST_CASES): print(f"[{i+1:02d}/{len(TEST_CASES)}] {case['tool']} ... ", end="", flush=True) result = await call_single_tool(client, case) results.append(result) status = "✅" if result["success"] else "❌" elapsed = result.get("elapsed_ms", 0) size = result.get("size", "N/A") print(f"{status} {elapsed:.0f}ms {size}") if not result["success"]: print(f" ⚠️ 错误: {result.get('error', 'N/A')[:120]}") elif result.get("data_preview"): preview = result["data_preview"][:120].replace("\n", " ") print(f" 📄 {preview}") # ── 第三阶段:生成报告 ── print(f"\n{'=' * 80}") print(f" 第三阶段:生成测试报告") print(f"{'=' * 80}\n") # 统计 total = len(results) passed = sum(1 for r in results if r["success"]) failed = sum(1 for r in results if not r["success"]) # 业务层面统计 biz_ok = sum(1 for r in results if r["success"] and r.get("deep_success")) biz_timeout = sum(1 for r in results if r["success"] and r.get("data_preview", "").find('"code": 408') > 0) biz_500 = sum(1 for r in results if r["success"] and r.get("data_preview", "").find('"code": 500') > 0) total_time = sum(r.get("elapsed_ms", 0) for r in results) # 生成 JSON 报告 report_json_path = os.path.join(os.path.dirname(__file__), "mcp_test_report.json") report = { "title": "通风模型算法 MCP 接口测试报告", "server": MCP_URL, "model_id": MODEL_ID, "timestamp": datetime.now().isoformat(), "summary": { "total": total, "passed": passed, "failed": failed, "pass_rate": f"{passed / total * 100:.1f}%" if total > 0 else "N/A", "biz_ok": biz_ok, "biz_timeout": biz_timeout, "biz_500": biz_500, "total_elapsed_ms": round(total_time, 2), "avg_elapsed_ms": round(total_time / total, 2) if total > 0 else 0, }, "results": results, } with open(report_json_path, "w", encoding="utf-8") as f: json.dump(report, f, ensure_ascii=False, indent=2, default=str) print(f" JSON 报告已保存: {report_json_path}") # 生成 Markdown 报告 md_path = os.path.join(os.path.dirname(__file__), "mcp_test_report.md") md_lines = [] md_lines.append("# 通风模型算法 MCP 接口测试报告\n") md_lines.append(f"**测试时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} \n") md_lines.append(f"**MCP 服务地址**: `{MCP_URL}` \n") md_lines.append(f"**测试模型 ID**: `{MODEL_ID}` \n") md_lines.append(f"**传输协议**: `streamable-http` \n") md_lines.append(f"**客户端库**: `fastmcp` \n") md_lines.append("") # 汇总 md_lines.append("## 📊 测试汇总\n") md_lines.append("### 传输层结果\n") md_lines.append("| 指标 | 数值 |") md_lines.append("|:---|---:|") md_lines.append(f"| 接口总数 | {total} |") md_lines.append(f"| ✅ 传输层通过 | {passed} |") md_lines.append(f"| ❌ 传输层失败 | {failed} |") md_lines.append(f"| 传输层通过率 | {passed / total * 100:.1f}% |") md_lines.append(f"| 总耗时 | {total_time:.0f} ms |") md_lines.append(f"| 平均响应 | {total_time / total:.0f} ms |") md_lines.append("") md_lines.append("### 业务层结果\n") md_lines.append("| 指标 | 数值 |") md_lines.append("|:---|---:|") md_lines.append(f"| ✅ 业务正常 | {biz_ok} |") md_lines.append(f"| ⏱️ 服务端超时 (408) | {biz_timeout} |") md_lines.append(f"| 🔴 服务端错误 (500) | {biz_500} |") md_lines.append(f"| ❌ 客户端校验失败 | {failed} |") md_lines.append(f"| 综合可用率 | {biz_ok / total * 100:.1f}% |") md_lines.append("") # 分类统计 md_lines.append("### 分类统计\n") md_lines.append("| 分类 | 总数 | 通过 | 失败 | 平均耗时 |") md_lines.append("|:---|---:|---:|---:|---:|") categories = {} for r in results: cat = r["category"] if cat not in categories: categories[cat] = {"total": 0, "passed": 0, "failed": 0, "times": []} categories[cat]["total"] += 1 if r["success"]: categories[cat]["passed"] += 1 else: categories[cat]["failed"] += 1 categories[cat]["times"].append(r.get("elapsed_ms", 0)) for cat, stats in categories.items(): avg_t = sum(stats["times"]) / len(stats["times"]) if stats["times"] else 0 md_lines.append(f"| {cat} | {stats['total']} | {stats['passed']} | {stats['failed']} | {avg_t:.0f} ms |") md_lines.append("") # 详细结果 md_lines.append("## 📋 详细测试结果\n") for r in results: # 确定状态图标 if not r["success"]: status_icon = "❌" biz_label = "客户端参数校验失败" elif r.get("deep_success") is False: preview = r.get("data_preview", "") if '"code": 408' in preview or "408" in preview: status_icon = "⏱️" biz_label = "服务端超时 (408)" elif '"code": 500' in preview or "500" in preview: status_icon = "🔴" biz_label = "服务端内部错误 (500)" elif '"code": 503' in preview or "503" in preview: status_icon = "🔴" biz_label = "服务不可用 (503)" else: status_icon = "⚠️" biz_label = "业务异常" else: status_icon = "✅" biz_label = "正常" md_lines.append(f"### {status_icon} {r['id']}: {r['desc']} (`{r['tool']}`)\n") md_lines.append(f"- **分类**: {r['category']}") md_lines.append(f"- **传输状态**: {'通过' if r['success'] else '失败'} | **业务状态**: {biz_label}") md_lines.append(f"- **耗时**: {r.get('elapsed_ms', 'N/A')} ms") if r["success"]: md_lines.append(f"- **结果大小**: {r.get('size', 'N/A')}") md_lines.append(f"- **数据结构**: `{r.get('structure', 'N/A')}`") else: md_lines.append(f"- **错误类型**: `{r.get('error_type', 'N/A')}`") md_lines.append(f"- **错误信息**: {r.get('error', 'N/A')[:300]}") # 参数 params_str = ", ".join(f"`{k}={v}`" for k, v in r["params"].items()) md_lines.append(f"- **调用参数**: {params_str}") # 数据预览 if r.get("data_preview"): preview = r["data_preview"] if len(preview) > 500: preview = preview[:500] + "..." md_lines.append(f"\n
\n数据预览\n\n```json\n{preview}\n```\n
") md_lines.append("") # 建议 md_lines.append("## 💡 建议与注意事项\n") md_lines.append(f"### ⚠️ 需要修复的问题({biz_timeout + biz_500} 个)\n") md_lines.append("| 接口 | 问题 | 建议 |") md_lines.append("|:---|:---|:---|") for r in results: preview = r.get("data_preview", "") if '"code": 408' in preview or "408" in preview: md_lines.append(f"| `{r['tool']}` | 服务端超时 (408),耗时 {r.get('elapsed_ms', 0):.0f}ms | 优化后端算法或增加超时时间;前端设置 180s+ 超时并显示进度 |") elif '"code": 500' in preview or "500" in preview: md_lines.append(f"| `{r['tool']}` | 服务端内部错误 (500) - Connection prematurely closed | 检查后端 netty 连接池配置,可能需要增大响应超时 |") elif '"code": 503' in preview or "503" in preview: md_lines.append(f"| `{r['tool']}` | 服务不可用 (503) - Unable to find instance | 检查微服务 `ventanaly-model` 实例是否在线/注册中心状态 |") md_lines.append("") md_lines.append("### 📝 通用建议\n") md_lines.append("1. **响应时间差异大**:快速接口(进/回风井查询、循环风路检查)约 0.8-1.5s,重计算接口(压能图、避灾路线)约 12-35s,超时接口约 180s。建议按接口类型设置差异化超时。\n") md_lines.append("2. **数据量大**:`get_three_area_distribution` (366KB) 和 `get_path_press_power` (3.8MB) 返回数据量大,移动端需考虑分页或压缩。\n") md_lines.append("3. **参数校验**:`net_cal_for_plan` 的 `plan` 参数不能为空字符串,建议后端明确 `plan` 格式规范或提供示例。\n") md_lines.append("4. **返回值统一性**:外层统一为 `{success, code, result}`,但 `get_model_fault_diagnosis` 直接在外层返回 `modelID`,略有不一致。\n") md_lines.append("5. **节点 vs 隧道 ID**:`get_max_resistance_path` 和 `get_path_press_power` 使用 `node_id`(节点ID),其他接口使用 `tun_id`(隧道ID),文档已说明,但调用时容易混淆。\n") md_lines.append("6. **CO 参数**:`get_escape_path_each_exit` 的 `co_per` 默认 2000.0,`during_time` 默认 0.0,合理默认值便于调用。\n") with open(md_path, "w", encoding="utf-8") as f: f.write("\n".join(md_lines)) print(f" Markdown 报告已保存: {md_path}") # ── 终端输出汇总 ── print(f"\n{'=' * 80}") print(f" 📊 测试汇总") print(f" 总计: {total} | ✅ 通过: {passed} | ❌ 失败: {failed} | 通过率: {passed / total * 100:.1f}%") print(f" 总耗时: {total_time:.0f} ms | 平均: {total_time / total:.0f} ms") print(f"{'=' * 80}") if __name__ == "__main__": asyncio.run(main())