package main import ( "context" "encoding/json" "fmt" "os" "path/filepath" "strings" "time" "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/mcp" ) // L1 用例:参数校验(不依赖后端,期望 isError=true 且消息含关键字) func runL1(ctx context.Context, c *client.Client) { type l1 struct { id, tool string args map[string]any wantSub string // 期望错误消息包含的子串(空=只要 isError) } cases := []l1{ {"L1-1", "util_parse_sn", map[string]any{"sn": "10060006A03X25310091"}, ""}, // 正确性单独断言 {"L1-3", "util_parse_sn", map[string]any{}, "sn"}, {"L1-4", "util_parse_sn", map[string]any{"sn": "1006"}, "长度"}, {"L1-5", "service_site_query_info", map[string]any{}, "sn"}, {"L1-6", "service_site_power_flow", map[string]any{"sn": "ANY", "date": "2025/01/01"}, "date"}, {"L1-7", "service_sim_monthly_data", map[string]any{"giccid": "x"}, "month"}, {"L1-8", "data_mongo_find", map[string]any{"collection": "UserInfo"}, "不允许"}, {"L1-9", "data_mongo_find", map[string]any{"collection": "SiteInfoManageData", "filter": "{bad json"}, "filter"}, {"L1-10", "data_redis_exists", map[string]any{"key": "foo*"}, "通配符"}, {"L1-11", "data_redis_get", map[string]any{"key": "any", "type": "zset"}, "可选值"}, {"L1-12", "storage_minio_list_objects", map[string]any{"bucket": "secret-bucket"}, "不允许"}, {"L1-13", "data_emspara_vid_info", map[string]any{"id": "abc"}, "数字"}, {"L1-14", "data_pgsql_query_snapshot_data", map[string]any{"sn": "ANY", "date": "20250101"}, "date"}, {"L1-16", "service_feedback_create", map[string]any{"title": "t", "type": "invalid"}, "bug"}, } for _, tc := range cases { t0 := time.Now() text, isErr, cErr := callText(ctx, c, tc.tool, tc.args) el := time.Since(t0) if cErr != nil { addResult(result{tc.id, tc.tool, "L1", FAIL, el, "调用异常: " + cErr.Error()}) continue } // L1-1 特殊:正确性断言 if tc.id == "L1-1" { ok := strings.Contains(text, "aGate") && strings.Contains(text, "2025") && strings.Contains(text, "0091") && strings.Contains(text, "\"week\": \"31\"") st, note := PASS, "SN 解析字段正确" if !ok { st, note = FAIL, "解析结果不符: "+truncate(text, 200) } addResult(result{tc.id, tc.tool, "L1", st, el, note}) continue } // 其余:期望 isError 且含子串 st := PASS note := "正确返回参数校验错误" if !isErr { st, note = FAIL, "未返回 isError;输出: "+truncate(text, 150) } else if tc.wantSub != "" && !strings.Contains(text, tc.wantSub) { st, note = FAIL, fmt.Sprintf("错误消息未含 %q: %s", tc.wantSub, truncate(text, 150)) } addResult(result{tc.id, tc.tool, "L1", st, el, note}) } } // L2 只读冒烟:预设关键用例 + 对无必填参数的只读工具自动冒烟 func runL2(ctx context.Context, c *client.Client, tools []mcp.Tool, byName map[string]mcp.Tool) { type l2 struct { id, tool string args map[string]any } preset := []l2{ // Redis {"L2-R1", "data_redis_exists", map[string]any{"key": "nonexistent_test_key_xyz"}}, {"L2-R2", "data_redis_get", map[string]any{"key": "nonexistent_test_key_xyz"}}, // Mongo {"L2-M1", "data_mongo_count", map[string]any{"collection": "SiteInfoManageData"}}, {"L2-M2", "data_mongo_find", map[string]any{"collection": "SiteInfoManageData", "limit": 1}}, {"L2-M3", "data_mongo_count", map[string]any{"collection": "StatisticsAlarm"}}, // MinIO / 文件 {"L2-S1", "storage_minio_list_objects", map[string]any{"bucket": "fwh-toolbox", "limit": 5}}, {"L2-S3", "storage_file_list", map[string]any{}}, // Plane {"L2-PL1", "plane_list_workspaces", map[string]any{}}, {"L2-PL2", "plane_list_projects", map[string]any{}}, {"L2-PL4", "plane_list_labels", map[string]any{}}, {"L2-PL5", "plane_list_states", map[string]any{}}, {"L2-PL6", "service_feedback_history", map[string]any{}}, // 业务层(远程 MySQL) {"L2-B1", "service_site_search", map[string]any{"page": 1, "size": 3}}, {"L2-B2", "service_sim_search", map[string]any{"page": 1, "size": 3}}, {"L2-B3", "service_monitor_alarm_stats", map[string]any{}}, {"L2-B4", "service_monitor_shutdown_stats", map[string]any{}}, {"L2-B5", "service_dashboard_shutdown_date", map[string]any{}}, {"L2-B6", "service_dashboard_shutdown_detail", map[string]any{}}, {"L2-B7", "service_dashboard_alarm_detail", map[string]any{}}, {"L2-B8", "service_tou_template_list", map[string]any{"page": 1, "size": 3}}, {"L2-B9", "service_background_task_list", map[string]any{}}, {"L2-B10", "service_site_monitor_config_list", map[string]any{}}, {"L2-B11", "service_site_monitor_name_list", map[string]any{}}, {"L2-B12", "service_site_info_update_status", map[string]any{}}, {"L2-B13", "service_site_info_tags_list", map[string]any{"page": 1, "size": 3}}, {"L2-B14", "service_auto_update_get_options", map[string]any{}}, {"L2-B15", "service_auto_update_get_apower_version", map[string]any{}}, {"L2-B16", "service_auto_update_file_list", map[string]any{"page": 1, "size": 3}}, {"L2-B17", "service_batch_download_list", map[string]any{"page": 1, "size": 3}}, {"L2-B18", "service_site_analysis_list", map[string]any{"page": 1, "size": 3}}, // emspara {"L2-F8a", "data_emspara_vid_info", map[string]any{"id": "1"}}, {"L2-F8b", "data_emspara_pid_info", map[string]any{"id": "1"}}, } seen := map[string]bool{} for _, tc := range preset { if _, ok := byName[tc.tool]; !ok { addResult(result{tc.id, tc.tool, "L2", SKIP, 0, "工具未注册"}) continue } seen[tc.tool] = true runOneL2(ctx, c, tc.id, tc.tool, tc.args) } // 自动冒烟:无必填参数、只读(非写操作、无 [写操作] 标记)、且未在 preset 覆盖的工具 auto := 0 for _, t := range tools { if seen[t.Name] { continue } if isWriteTool(t) { continue // 写操作跳过 } if len(t.InputSchema.Required) > 0 { // 需要必填参数(多为需真实样本 SN/GICCID/ID),本轮无样本 → SKIP addResult(result{"L2-auto", t.Name, "L2", SKIP, 0, "需必填参数,缺测试样本: " + strings.Join(t.InputSchema.Required, ",")}) continue } auto++ runOneL2(ctx, c, fmt.Sprintf("L2-auto%d", auto), t.Name, map[string]any{}) } } func runOneL2(ctx context.Context, c *client.Client, id, tool string, args map[string]any) { t0 := time.Now() text, isErr, cErr := callText(ctx, c, tool, args) el := time.Since(t0) if cErr != nil { // 传输层错误 → FAIL addResult(result{id, tool, "L2", FAIL, el, "调用异常: " + cErr.Error()}) return } if isErr { // 判定是连接类(SKIP)还是真实错误(FAIL) if looksLikeDependencyError(text) { addResult(result{id, tool, "L2", SKIP, el, "依赖不可用/凭据: " + truncate(text, 120)}) } else { // 参数校验类错误(如需真实样本)也归 SKIP,其它归 FAIL addResult(result{id, tool, "L2", SKIP, el, "返回错误: " + truncate(text, 120)}) } return } // 成功:结构合法即 PASS(含"查询结果为空"等业务空结果) addResult(result{id, tool, "L2", PASS, el, "返回合法结果 " + fmt.Sprintf("(%dB)", len(text))}) } // L4 敏感数据脱敏(端到端,用生产库真实数据做负向/正向断言) func runL4(ctx context.Context, c *client.Client, byName map[string]mcp.Tool) { type l4 struct { id, tool string args map[string]any keep []string // 正向断言:应保留的字段名(出现即视为保留生效) } cases := []l4{ {"L4-2", "service_site_search", map[string]any{"page": 1, "size": 5}, []string{"sn"}}, {"L4-4", "service_sim_search", map[string]any{"page": 1, "size": 5}, []string{"giccid"}}, {"L4-5", "data_mongo_find", map[string]any{"collection": "SiteInfoManageData", "limit": 3}, []string{"_id"}}, {"L4-6", "data_mongo_find", map[string]any{"collection": "SimCardManage", "limit": 3}, []string{"_id"}}, } for _, tc := range cases { if _, ok := byName[tc.tool]; !ok { piis = append(piis, piiFinding{tc.id, tc.tool, SKIP, nil, "工具未注册", ""}) addResult(result{tc.id, tc.tool, "L4", SKIP, 0, "工具未注册"}) continue } t0 := time.Now() text, isErr, cErr := callText(ctx, c, tc.tool, tc.args) el := time.Since(t0) if cErr != nil { piis = append(piis, piiFinding{tc.id, tc.tool, FAIL, nil, "调用异常", cErr.Error()}) addResult(result{tc.id, tc.tool, "L4", FAIL, el, "调用异常: " + cErr.Error()}) continue } if isErr || looksLikeDependencyError(text) { piis = append(piis, piiFinding{tc.id, tc.tool, SKIP, nil, "依赖不可用或空数据", truncate(text, 200)}) addResult(result{tc.id, tc.tool, "L4", SKIP, el, "依赖不可用/空: " + truncate(text, 100)}) continue } // 若结果落盘,读取本地文件内容一起检测 full := text if lp := extractLocalPath(text); lp != "" { if b, err := os.ReadFile(lp); err == nil { full = text + "\n" + string(b) } } leaks := detectLeaks(full) // 正向断言 pos := "n/a" if len(tc.keep) > 0 { hit := []string{} for _, k := range tc.keep { if strings.Contains(full, "\""+k+"\"") { hit = append(hit, k) } } pos = "保留字段命中: " + strings.Join(hit, ",") } st := PASS note := "无明文 PII" if len(leaks) > 0 { st = FAIL note = "检出明文 PII: " + strings.Join(leaks, "; ") } piis = append(piis, piiFinding{tc.id, tc.tool, st, leaks, pos, truncate(full, 600)}) addResult(result{tc.id, tc.tool, "L4", st, el, note + "; " + pos}) } } // runSampleCases 用真实样本补测之前 SKIP 的只读工具(含 PII 端到端检测)。 func runSampleCases(ctx context.Context, c *client.Client, byName map[string]mcp.Tool) { type sc struct { id, tool string args map[string]any pii bool // 是否做明文 PII 负向断言 keep []string // 正向断言:保留字段 } cases := []sc{ // 站点(FWH 远程 + PII) {"L2S-1", "service_site_query_info", map[string]any{"sn": sampleSN}, true, []string{"sn"}}, {"L2S-2", "service_site_power_flow", map[string]any{"sn": sampleSN, "date": "2026-08-24"}, false, nil}, {"L2S-3", "data_fwh_query_site_mode_info", map[string]any{"sn": sampleSN}, false, nil}, {"L2S-4", "data_fwh_query_site_alarm_list", map[string]any{"sn": sampleSN}, false, nil}, {"L2S-5", "data_fwh_query_system_update_log", map[string]any{"sn": sampleSN}, false, nil}, {"L2S-6", "service_site_monitor_mode_info", map[string]any{"sn": sampleSN}, false, nil}, {"L2S-7", "service_site_monitor_alarm_list", map[string]any{"sn": sampleSN}, false, nil}, {"L2S-8", "service_site_monitor_base_info", map[string]any{"site_list": `["` + sampleSN + `"]`}, false, nil}, // SIM(PII) {"L2S-10", "service_sim_query", map[string]any{"giccid": sampleGICCID}, true, []string{"giccid"}}, {"L2S-11", "service_sim_monthly_data", map[string]any{"giccid": sampleGICCID, "month": "2026-08"}, false, nil}, {"L2S-12", "data_sim_smarthub_card_info", map[string]any{"giccid": sampleGICCID}, true, nil}, // Plane(真实 slug/project) {"L2S-20", "plane_list_projects", map[string]any{"workspace_slug": samplePlaneSlug}, false, nil}, {"L2S-21", "plane_get_project", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, false, nil}, {"L2S-22", "plane_list_issues", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, false, nil}, {"L2S-23", "plane_list_labels", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, false, nil}, {"L2S-24", "plane_list_states", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, false, nil}, {"L2S-25", "plane_list_cycles", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, false, nil}, {"L2S-26", "plane_list_modules", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, false, nil}, {"L2S-27", "plane_list_intake_issues", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, false, nil}, {"L2S-28", "plane_list_project_members", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, false, nil}, {"L2S-29", "plane_list_workspace_members", map[string]any{"workspace_slug": samplePlaneSlug}, false, nil}, } for _, tc := range cases { if _, ok := byName[tc.tool]; !ok { addResult(result{tc.id, tc.tool, "L2S", SKIP, 0, "工具未注册"}) continue } t0 := time.Now() text, isErr, cErr := callText(ctx, c, tc.tool, tc.args) el := time.Since(t0) if cErr != nil { addResult(result{tc.id, tc.tool, "L2S", FAIL, el, "调用异常: " + cErr.Error()}) continue } if isErr { // 样本用例:仅在 isError 时判定;区分依赖问题与真实错误 if looksLikeDependencyError(text) { addResult(result{tc.id, tc.tool, "L2S", SKIP, el, "依赖不可用/空: " + truncate(text, 120)}) } else { addResult(result{tc.id, tc.tool, "L2S", FAIL, el, "返回错误: " + truncate(text, 120)}) } continue } // PII 检测 if tc.pii { full := text if lp := extractLocalPath(text); lp != "" { if b, err := os.ReadFile(lp); err == nil { full = text + "\n" + string(b) } } leaks := detectLeaks(full) pos := "" for _, k := range tc.keep { if strings.Contains(full, "\""+k+"\"") { pos += k + " " } } st, note := PASS, "无明文 PII; 保留:"+pos if len(leaks) > 0 { st, note = FAIL, "检出明文 PII: "+strings.Join(leaks, "; ") } piis = append(piis, piiFinding{tc.id, tc.tool, st, leaks, "保留字段: " + pos, truncate(full, 600)}) addResult(result{tc.id, tc.tool, "L2S", st, el, note}) continue } addResult(result{tc.id, tc.tool, "L2S", PASS, el, fmt.Sprintf("返回合法结果 (%dB)", len(text))}) } } // runWriteLoop L3RW:MinIO/文件 写→读→列 闭环(用户已授权 MinIO 写 + file 读)。 func runWriteLoop(ctx context.Context, c *client.Client, byName map[string]mcp.Tool) { if _, ok := byName["storage_file_write"]; !ok { addResult(result{"L3RW-1", "storage_file_write", "L3RW", SKIP, 0, "工具未注册"}) return } testPath := fmt.Sprintf("/uploads/mcp_test_%d.txt", time.Now().Unix()) testContent := fmt.Sprintf("MCP write-read loop test @ %s\nno-pii-content", time.Now().Format(time.RFC3339)) // 1) write t0 := time.Now() wText, wErr, cErr := callText(ctx, c, "storage_file_write", map[string]any{ "path": testPath, "content": testContent, "userId": "mcp-test", "sessionId": "mcp-test", }) if cErr != nil { addResult(result{"L3RW-1", "storage_file_write", "L3RW", FAIL, time.Since(t0), "调用异常: " + cErr.Error()}) return } if wErr { if looksLikeDependencyError(wText) { addResult(result{"L3RW-1", "storage_file_write", "L3RW", SKIP, time.Since(t0), "依赖不可用: " + truncate(wText, 120)}) return } addResult(result{"L3RW-1", "storage_file_write", "L3RW", FAIL, time.Since(t0), "写入失败: " + truncate(wText, 120)}) return } // 从返回中解析实际存储 path(handler 会加 /uploads/ 前缀) storedPath := testPath if p := extractJSONString(wText, "path"); p != "" { storedPath = p } addResult(result{"L3RW-1", "storage_file_write", "L3RW", PASS, time.Since(t0), "写入成功 path=" + storedPath}) // 2) list(应含刚写入的文件) t0 = time.Now() lText, lErr, _ := callText(ctx, c, "storage_file_list", map[string]any{}) st, note := PASS, "列表返回" if lErr { st, note = SKIP, "列表返回错误: "+truncate(lText, 100) } else if !strings.Contains(lText, "mcp_test_") { st, note = FAIL, "列表未包含刚写入的文件" } addResult(result{"L3RW-2", "storage_file_list", "L3RW", st, time.Since(t0), note}) // 3) read(读回并校验内容) if _, ok := byName["storage_file_read"]; ok { t0 = time.Now() rText, rErr, rcErr := callText(ctx, c, "storage_file_read", map[string]any{"path": storedPath}) rst, rnote := PASS, "读回内容一致" if rcErr != nil { rst, rnote = FAIL, "调用异常: "+rcErr.Error() } else if rErr { rst, rnote = SKIP, "读取错误: "+truncate(rText, 100) } else if !strings.Contains(rText, "no-pii-content") { rst, rnote = FAIL, "读回内容与写入不一致: "+truncate(rText, 100) } addResult(result{"L3RW-3", "storage_file_read", "L3RW", rst, time.Since(t0), rnote}) } // 4) minio list(验证对象确实落到 MinIO) if _, ok := byName["storage_minio_list_objects"]; ok { t0 = time.Now() mText, mErr, _ := callText(ctx, c, "storage_minio_list_objects", map[string]any{"bucket": "fwh-toolbox", "prefix": "", "limit": 50}) mst, mnote := PASS, "MinIO 可列出对象" if mErr { mst, mnote = SKIP, "MinIO 列出错误: "+truncate(mText, 100) } addResult(result{"L3RW-4", "storage_minio_list_objects", "L3RW", mst, time.Since(t0), mnote}) } } // runCascadeCases L2C:从已 PASS 的列表接口提取 id/key/name,再喂给详情接口。 // source: 先调用的列表工具;srcArgs: 其参数;extract: 从返回 JSON 中取值的路径。 // target: 待补测的详情工具;argKey: 目标工具接收该值的参数名;extraArgs: 目标工具的其它固定参数。 func runCascadeCases(ctx context.Context, c *client.Client, byName map[string]mcp.Tool) { type cascade struct { id string source string srcArgs map[string]any extract string // 值提取路径(见 jsonExtract) target string argKey string extraArgs map[string]any } cs := []cascade{ // auto_update: options → package / config {"L2C-1", "service_auto_update_get_options", map[string]any{}, "packages.system[0]", "service_auto_update_get_package", "file_name", nil}, {"L2C-2", "service_auto_update_get_options", map[string]any{}, "aGateVersions[0]", "service_auto_update_get_config", "agate_version", nil}, // tou: list → detail {"L2C-3", "service_tou_template_list", map[string]any{"page": 1, "size": 1}, "data[0].id", "service_tou_template_detail", "id", nil}, {"L2C-4", "service_tou_template_list", map[string]any{"page": 1, "size": 1}, "data[0].id", "data_fwh_query_tou_template_detail", "id", nil}, // site_analysis: list → get_config / data {"L2C-5", "service_site_analysis_list", map[string]any{"page": 1, "size": 1}, "data[0].id", "service_site_analysis_get_config", "id", nil}, {"L2C-6", "service_site_analysis_list", map[string]any{"page": 1, "size": 1}, "data[0].id", "service_site_analysis_data", "id", nil}, // batch_download: list → path / file_tree {"L2C-7", "service_batch_download_list", map[string]any{"page": 1, "size": 1}, "data[0].id", "service_batch_download_path", "id", nil}, // site_monitor: name_list → config_get {"L2C-8", "service_site_monitor_name_list", map[string]any{}, "[0]", "service_site_monitor_config_get", "name", nil}, // acceleronix: card_list → card_info;data_pool_list → data_pool_sim_info {"L2C-9", "data_sim_acceleronix_card_list", map[string]any{"page": 1, "size": 1}, "data.list[0].iccid", "data_sim_acceleronix_card_info", "iccid", nil}, {"L2C-10", "data_sim_acceleronix_data_pool_list", map[string]any{"page": 1, "size": 1}, "data.list[0].dataPoolId", "data_sim_acceleronix_data_pool_sim_info", "data_pool_id", nil}, // system_update_log(sample SN) → detail(order_id=id) {"L2C-11", "data_fwh_query_system_update_log", map[string]any{"sn": sampleSN}, "[0].id", "data_fwh_query_system_update_log_detail", "order_id", map[string]any{"sn": sampleSN}}, // minio: list_objects → read_object {"L2C-12", "storage_minio_list_objects", map[string]any{"bucket": "fwh-toolbox", "limit": 20}, "objects[0].key", "storage_minio_read_object", "key", map[string]any{"bucket": "fwh-toolbox"}}, // plane issues: list → get_issue / sub_issues / activity(若 results 非空) {"L2C-13", "plane_list_issues", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, "results[0].id", "plane_get_issue", "issue_id", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}}, {"L2C-14", "plane_list_cycles", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, "[0].id", "plane_get_cycle", "cycle_id", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}}, {"L2C-15", "plane_list_modules", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, "[0].id", "plane_get_module", "module_id", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}}, {"L2C-16", "plane_list_pages", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, "[0].id", "plane_get_page", "page_id", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}}, {"L2C-17", "plane_list_intake_issues", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}, "results[0].issue.id", "plane_get_intake_issue", "issue_id", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}}, } for _, cc := range cs { if _, ok := byName[cc.target]; !ok { addResult(result{cc.id, cc.target, "L2C", SKIP, 0, "目标工具未注册"}) continue } // 1) 调用源列表接口 srcText, srcErr, cErr := callText(ctx, c, cc.source, cc.srcArgs) if cErr != nil || srcErr { addResult(result{cc.id, cc.target, "L2C", SKIP, 0, "源接口不可用: " + cc.source + " " + truncate(srcText, 80)}) continue } // 落盘情况:读取完整内容 full := srcText if lp := extractLocalPath(srcText); lp != "" { if b, err := os.ReadFile(lp); err == nil { full = string(b) } } // 2) 提取值 val := jsonExtract(full, cc.extract) if val == "" { addResult(result{cc.id, cc.target, "L2C", SKIP, 0, fmt.Sprintf("源 %s 无可用数据(路径 %s 为空)", cc.source, cc.extract)}) continue } // 3) 调用目标详情接口 args := map[string]any{cc.argKey: val} for k, v := range cc.extraArgs { args[k] = v } t0 := time.Now() tText, tErr, tcErr := callText(ctx, c, cc.target, args) el := time.Since(t0) if tcErr != nil { addResult(result{cc.id, cc.target, "L2C", FAIL, el, "调用异常: " + tcErr.Error()}) continue } if tErr { // storage_minio_read_object 的"文件过大/非文本"是工具正确的校验行为,判 PASS if cc.target == "storage_minio_read_object" && (strings.Contains(tText, "文件过大") || strings.Contains(tText, "非文本")) { addResult(result{cc.id, cc.target, "L2C", PASS, el, "校验行为正常(拒绝大/非文本文件): " + truncate(tText, 60)}) continue } if looksLikeDependencyError(tText) { addResult(result{cc.id, cc.target, "L2C", SKIP, el, fmt.Sprintf("%s=%s 依赖/空: %s", cc.argKey, truncate(val, 20), truncate(tText, 80))}) } else { addResult(result{cc.id, cc.target, "L2C", FAIL, el, fmt.Sprintf("%s=%s 返回错误: %s", cc.argKey, truncate(val, 20), truncate(tText, 80))}) } continue } addResult(result{cc.id, cc.target, "L2C", PASS, el, fmt.Sprintf("级联 %s=%s → %dB", cc.argKey, truncate(val, 24), len(tText))}) } } // jsonExtract 从 JSON 文本按简易路径取标量值,返回其字符串形式。 // 支持:对象键 a.b、数组下标 a[0]、根数组 [0]、混合 data.list[0].iccid。 func jsonExtract(jsonText, path string) string { var root any if err := json.Unmarshal([]byte(jsonText), &root); err != nil { return "" } cur := root // 将 [n] 归一化为 .n 便于按 . 分段 norm := strings.ReplaceAll(path, "[", ".") norm = strings.ReplaceAll(norm, "]", "") for _, seg := range strings.Split(norm, ".") { if seg == "" { continue } switch node := cur.(type) { case map[string]any: cur = node[seg] case []any: idx := 0 fmt.Sscanf(seg, "%d", &idx) if idx < 0 || idx >= len(node) { return "" } cur = node[idx] default: return "" } if cur == nil { return "" } } switch v := cur.(type) { case string: return v case float64: // 整数去掉小数点 if v == float64(int64(v)) { return fmt.Sprintf("%d", int64(v)) } return fmt.Sprintf("%v", v) case json.Number: return v.String() case bool: return fmt.Sprintf("%v", v) default: return "" } } // runExtraCascades L2X:处理需递归级联或自造输入的补测。 func runExtraCascades(ctx context.Context, c *client.Client, byName map[string]mcp.Tool) { // 1) data_fwh_query_site_power_flow —— 直接用样本 SN+date runOneL2X(ctx, c, byName, "L2X-1", "data_fwh_query_site_power_flow", map[string]any{"sn": sampleSN, "date": "2026-08-24"}) // 2) plane_get_workspace —— slug=fwh runOneL2X(ctx, c, byName, "L2X-2", "plane_get_workspace", map[string]any{"workspace_slug": samplePlaneSlug}) // 3) plane_list_pages / plane_list_stickies —— slug/project runOneL2X(ctx, c, byName, "L2X-3", "plane_list_pages", map[string]any{"workspace_slug": samplePlaneSlug, "project_id": samplePlaneProject}) runOneL2X(ctx, c, byName, "L2X-4", "plane_list_stickies", map[string]any{"workspace_slug": samplePlaneSlug}) // 4) service_batch_download_file_tree —— 从 batch list → path 树递归取第一个叶子文件 runFileTreeCascade(ctx, c, byName) // 5) storage_file_read_to_local —— 先写一个文件再 read_to_local runReadToLocalCascade(ctx, c, byName) // 6) storage_file_upload —— 自造本地临时文件再上传 runUploadCascade(ctx, c, byName) } // runOneL2X 调用单个工具并按三态判定(样本已知有效,只区分 isErr 与依赖/空)。 func runOneL2X(ctx context.Context, c *client.Client, byName map[string]mcp.Tool, id, tool string, args map[string]any) { if _, ok := byName[tool]; !ok { addResult(result{id, tool, "L2X", SKIP, 0, "工具未注册"}) return } t0 := time.Now() text, isErr, cErr := callText(ctx, c, tool, args) el := time.Since(t0) if cErr != nil { addResult(result{id, tool, "L2X", FAIL, el, "调用异常: " + cErr.Error()}) return } if isErr { if looksLikeDependencyError(text) { addResult(result{id, tool, "L2X", SKIP, el, "依赖/空: " + truncate(text, 100)}) } else { addResult(result{id, tool, "L2X", FAIL, el, "返回错误: " + truncate(text, 100)}) } return } addResult(result{id, tool, "L2X", PASS, el, fmt.Sprintf("返回合法结果 (%dB)", len(text))}) } // runFileTreeCascade batch list → path 树 → 递归取第一个 isFile 叶子 → file_tree。 func runFileTreeCascade(ctx context.Context, c *client.Client, byName map[string]mcp.Tool) { const id, tool = "L2X-5", "service_batch_download_file_tree" if _, ok := byName[tool]; !ok { addResult(result{id, tool, "L2X", SKIP, 0, "工具未注册"}) return } // 取 batch id lText, lErr, _ := callText(ctx, c, "service_batch_download_list", map[string]any{"page": 1, "size": 1}) if lErr { addResult(result{id, tool, "L2X", SKIP, 0, "batch list 不可用"}) return } bid := jsonExtract(lText, "data[0].id") if bid == "" { addResult(result{id, tool, "L2X", SKIP, 0, "无 batch 任务可用"}) return } // 取 path 树 pText, pErr, _ := callText(ctx, c, "service_batch_download_path", map[string]any{"id": bid}) if pErr { addResult(result{id, tool, "L2X", SKIP, 0, "batch path 不可用"}) return } full := pText if lp := extractLocalPath(pText); lp != "" { if b, err := os.ReadFile(lp); err == nil { full = string(b) } } leaf := findFirstFileLeaf(full) if leaf == "" { addResult(result{id, tool, "L2X", SKIP, 0, "path 树中无文件叶子(tar 文件路径)"}) return } t0 := time.Now() tText, tErr, tcErr := callText(ctx, c, tool, map[string]any{"file_path": leaf}) el := time.Since(t0) if tcErr != nil { addResult(result{id, tool, "L2X", FAIL, el, "调用异常: " + tcErr.Error()}) return } if tErr { if looksLikeDependencyError(tText) { addResult(result{id, tool, "L2X", SKIP, el, "依赖/空: " + truncate(tText, 100)}) } else { addResult(result{id, tool, "L2X", FAIL, el, "返回错误: " + truncate(tText, 100)}) } return } addResult(result{id, tool, "L2X", PASS, el, "级联 file_path=" + truncate(leaf, 40) + fmt.Sprintf(" → %dB", len(tText))}) } // findFirstFileLeaf 递归查找 tree JSON 中第一个 isFile==true 节点的 name。 func findFirstFileLeaf(jsonText string) string { var root any if err := json.Unmarshal([]byte(jsonText), &root); err != nil { return "" } var dfs func(n any) string dfs = func(n any) string { switch node := n.(type) { case map[string]any: if f, ok := node["isFile"].(bool); ok && f { if name, ok := node["name"].(string); ok { return name } } if ch, ok := node["children"]; ok { if r := dfs(ch); r != "" { return r } } case []any: for _, e := range node { if r := dfs(e); r != "" { return r } } } return "" } return dfs(root) } // runReadToLocalCascade 写一个文件 → storage_file_read_to_local 读到本地。 func runReadToLocalCascade(ctx context.Context, c *client.Client, byName map[string]mcp.Tool) { const id, tool = "L2X-6", "storage_file_read_to_local" if _, ok := byName[tool]; !ok { addResult(result{id, tool, "L2X", SKIP, 0, "工具未注册"}) return } path := fmt.Sprintf("/uploads/mcp_test_r2l_%d.txt", time.Now().UnixNano()) wText, wErr, _ := callText(ctx, c, "storage_file_write", map[string]any{ "path": path, "content": "read-to-local test", "userId": "mcp-test", "sessionId": "mcp-test", }) if wErr { addResult(result{id, tool, "L2X", SKIP, 0, "前置写入失败: " + truncate(wText, 80)}) return } stored := path if p := extractJSONString(wText, "path"); p != "" { stored = p } t0 := time.Now() rText, rErr, rcErr := callText(ctx, c, tool, map[string]any{"path": stored}) el := time.Since(t0) if rcErr != nil { addResult(result{id, tool, "L2X", FAIL, el, "调用异常: " + rcErr.Error()}) return } if rErr { addResult(result{id, tool, "L2X", SKIP, el, "返回错误: " + truncate(rText, 80)}) return } addResult(result{id, tool, "L2X", PASS, el, "写入后下载到本地成功"}) } // runUploadCascade 自造本地临时文件 → storage_file_upload。 func runUploadCascade(ctx context.Context, c *client.Client, byName map[string]mcp.Tool) { const id, tool = "L2X-7", "storage_file_upload" if _, ok := byName[tool]; !ok { addResult(result{id, tool, "L2X", SKIP, 0, "工具未注册"}) return } tmp := fmt.Sprintf("/tmp/mcp_test_upload_%d.txt", time.Now().UnixNano()) if err := os.WriteFile(tmp, []byte("upload test content\nno-pii"), 0644); err != nil { addResult(result{id, tool, "L2X", SKIP, 0, "无法创建本地临时文件: " + err.Error()}) return } defer os.Remove(tmp) t0 := time.Now() uText, uErr, ucErr := callText(ctx, c, tool, map[string]any{ "localPath": tmp, "fileName": "mcp_test_upload.txt", "userId": "mcp-test", "sessionId": "mcp-test", }) el := time.Since(t0) if ucErr != nil { addResult(result{id, tool, "L2X", FAIL, el, "调用异常: " + ucErr.Error()}) return } if uErr { if looksLikeDependencyError(uText) { addResult(result{id, tool, "L2X", SKIP, el, "依赖/空: " + truncate(uText, 80)}) } else { addResult(result{id, tool, "L2X", FAIL, el, "返回错误: " + truncate(uText, 80)}) } return } addResult(result{id, tool, "L2X", PASS, el, "本地文件上传成功"}) } // ---- 判定辅助 ---- func isWriteTool(t mcp.Tool) bool { return strings.Contains(t.Description, "[写操作]") || strings.Contains(t.Description, "写操作") } func looksLikeDependencyError(text string) bool { kw := []string{"connection", "connect", "timeout", "refused", "no reachable", "dial ", "EOF", "登录", "login", "unauthorized", "authentication", "i/o timeout", "context deadline", "无法连接", "失败", "server selection"} lt := strings.ToLower(text) for _, k := range kw { if strings.Contains(lt, strings.ToLower(k)) { return true } } return false } func extractLocalPath(text string) string { return extractJSONString(text, "localPath") } // extractJSONString 粗略提取 "key": "value" 中的 value(用于报告展示,不做完整 JSON 解析)。 func extractJSONString(text, key string) string { idx := strings.Index(text, "\""+key+"\"") if idx < 0 { return "" } rest := text[idx+len(key)+2:] c1 := strings.Index(rest, ":") if c1 < 0 { return "" } rest = rest[c1+1:] q1 := strings.Index(rest, "\"") if q1 < 0 { return "" } rest = rest[q1+1:] q2 := strings.Index(rest, "\"") if q2 < 0 { return "" } return rest[:q2] } // ---- 报告 ---- func writeReports(outDir string) { writeReport(filepath.Join(outDir, "report.md")) writePiiReport(filepath.Join(outDir, "pii_report.md")) } func countByStatus(level string) (p, f, s int) { for _, r := range results { if level != "" && r.level != level { continue } switch r.status { case PASS: p++ case FAIL: f++ case SKIP: s++ } } return } // foldByTool 将每个工具的多条执行记录折叠为一条终态。 // 终态优先级:FAIL > PASS > SKIP(FAIL 必须暴露;有任一 PASS 即视为已覆盖;全 SKIP 才算未覆盖)。 // 返回 工具→终态 的映射。 func foldByTool() map[string]status { rank := map[status]int{SKIP: 0, PASS: 1, FAIL: 2} final := map[string]status{} for _, r := range results { if r.tool == "-" || r.tool == "" { continue } if cur, ok := final[r.tool]; !ok || rank[r.status] > rank[cur] { final[r.tool] = r.status } } return final } // toolCoverage 基于折叠后的终态统计工具级覆盖率。 func toolCoverage() (total, passed, failed, uncovered int) { for _, st := range foldByTool() { total++ switch st { case PASS: passed++ case FAIL: failed++ case SKIP: uncovered++ } } return } func writeReport(path string) { var b strings.Builder b.WriteString("# FWH MCP Server 非写操作接口测试报告\n\n") b.WriteString(fmt.Sprintf("生成时间: %s\n\n", time.Now().Format("2006-01-02 15:04:05"))) b.WriteString("> SKIP / FAIL 的逐条根因分析、代码定位与修复建议见 **ANALYSIS.md**。\n\n") // 主口径:按工具去重的覆盖率(每个工具只算一次终态,FAIL>PASS>SKIP) tt, tpass, tfail, tuncov := toolCoverage() b.WriteString("## 工具覆盖率(按工具去重,主口径)\n\n") b.WriteString(fmt.Sprintf("被测工具 **%d** 个 | ✅ 已覆盖(PASS) **%d** | ❌ FAIL **%d** | ⏭️ 未覆盖(SKIP) **%d**\n\n", tt, tpass, tfail, tuncov)) if tt > 0 { b.WriteString(fmt.Sprintf("覆盖率: **%.1f%%**(%d/%d 工具至少有一次成功调用)\n\n", float64(tpass)/float64(tt)*100, tpass, tt)) } b.WriteString("> 说明:一个工具可能在多个阶段被调用(自动冒烟因缺参 SKIP、样本/级联阶段 PASS)。\n") b.WriteString("> 上表按工具去重,只要该工具有任一次 PASS 即计为“已覆盖”,避免同一工具重复计数。\n") b.WriteString("> 下方“执行记录”为全部调用流水(含重复),供追溯过程。\n\n") // 次口径:执行记录流水 tp, tf, ts := countByStatus("") b.WriteString("## 执行记录统计(流水口径,含重复)\n\n") b.WriteString(fmt.Sprintf("总执行 %d 次 | PASS %d | FAIL %d | SKIP %d\n\n", len(results), tp, tf, ts)) b.WriteString("## 分层汇总(执行流水)\n\n| 层级 | PASS | FAIL | SKIP |\n|------|------|------|------|\n") for _, lv := range []string{"L0", "L1", "L2", "L2S", "L2C", "L2X", "L3RW", "L4"} { p, f, s := countByStatus(lv) b.WriteString(fmt.Sprintf("| %s | %d | %d | %d |\n", lv, p, f, s)) } b.WriteString("\n") b.WriteString("## 执行明细\n\n| 编号 | 层级 | 工具 | 结果 | 耗时 | 备注 |\n|------|------|------|------|------|------|\n") for _, r := range results { note := strings.ReplaceAll(r.note, "|", "\\|") note = strings.ReplaceAll(note, "\n", " ") b.WriteString(fmt.Sprintf("| %s | %s | %s | %s | %dms | %s |\n", r.id, r.level, r.tool, r.status, r.elapsed.Milliseconds(), truncate(note, 160))) } // FAIL 汇总 b.WriteString("\n## FAIL 用例(需关注)\n\n") anyFail := false for _, r := range results { if r.status == FAIL { anyFail = true b.WriteString(fmt.Sprintf("- **%s** %s: %s\n", r.id, r.tool, strings.ReplaceAll(r.note, "\n", " "))) } } if !anyFail { b.WriteString("无\n") } b.WriteString("\n## SKIP 逐条分析(每个工具缺什么 / 如何补)\n\n") writeSkipAnalysis(&b) os.WriteFile(path, []byte(b.String()), 0644) } // skipNeed 描述某工具 SKIP 的具体缺失与补法。 type skipNeed struct { group string // A=需样本(可级联) / B=需有数据样本 / C=环境或代码 / D=数据为空 need string // 缺什么、如何补 } // skipNeeds 映射每个可能 SKIP 的工具 → 缺失说明。 var skipNeeds = map[string]skipNeed{ // —— C:环境/代码,非样本 —— "service_background_task_list": {"C", "缺 asynq 任务队列 inspector 初始化。需在 cmd/mcp/main.go 初始化队列 inspector(代码改动),或该能力在 MCP 场景不启用。"}, "service_monitor_alarm_stats": {"D", "缺当日告警统计数据(StatisticsAlarm 集合无文档)。需等有数据的日期或造数据;工具逻辑正常(同族 shutdown 已 PASS)。"}, "service_dashboard_alarm_detail": {"D", "同上,缺 StatisticsAlarm 当日文档。"}, // —— B:需“确定有数据”的样本 —— "data_pgsql_query_snapshot_file_record": {"B", "缺一个已知存在的快照 file_name(PgSQL 记录,列表接口无法级联获取)。"}, "data_fwh_query_alarm_snapshot": {"B", "缺“确实有告警快照”的 sn+date 组合(普通 SN 当天不一定有快照)。"}, "data_fwh_query_alarm_statistics_from_code": {"B", "缺一个真实的 alarm_code(告警码)。"}, "data_fwh_query_mqtt_log_list": {"B", "缺有效的 cmd_type 取值 + 有 MQTT 日志的 sn 与时间范围。"}, "data_fwh_query_ems_log_record": {"B", "缺“有 EMS 日志”的 sn(现有样本 SN 试过为空)。"}, "data_fwh_query_event_alarm_list": {"B", "缺“该时段确有事件/告警”的 sn+start_time+end_time。"}, "data_fwh_query_site_alarm_list": {"B", "缺“确有告警”的 sn(现有样本 SN 无告警)。"}, "data_fwh_query_shutdown_list": {"B", "缺“该日期确有停机记录”的 date。"}, "data_fwh_query_ahub_update_log": {"B", "缺“有 aHub 升级记录”的 sn。"}, "data_fwh_query_fault_record_list": {"B", "缺“确有故障录波”的 sn+时间范围(现有 SN 上游返回 Response failed=无记录)。"}, "data_fwh_query_fault_record_callable_list": {"B", "缺“有可召唤故障录波”的 sn。"}, "data_fwh_query_fault_record_sub_list": {"B", "缺 fault_id(需先有 fault_record_list 返回数据才能级联)。"}, "data_fwh_query_system_update_log_detail": {"B", "order_id 已能级联获取,但上游对该 order 无详情(Response failed)。需一个确有升级详情的 order。"}, "data_sim_acceleronix_batch_data_info": {"B", "iccid 可级联,但需配一个“确有用量数据”的 month。"}, // —— Plane:列表为空导致无法级联(环境数据缺失)—— "plane_get_issue": {"D", "缺可用 issue:plane_list_issues 当前返回空(该 project 无工作项)。需项目里有 issue。"}, "plane_get_sub_issues": {"D", "同上,需存在带子项的 issue。"}, "plane_get_issue_activity": {"D", "同上,需存在 issue。"}, "plane_get_cycle": {"D", "缺 cycle:plane_list_cycles 返回空。需项目里有周期。"}, "plane_get_module": {"D", "缺 module:plane_list_modules 返回空。需项目里有模块。"}, "plane_get_page": {"D", "缺 page:plane_list_pages 返回空。需项目里有页面。"}, "plane_get_page_description": {"D", "同上,需存在 page。"}, "plane_get_workspace": {"A", "仅需 workspace_slug=fwh,驱动未覆盖直接调用(可级联/固定值补测)。"}, "plane_list_pages": {"A", "仅需 slug/project(有样本),驱动未覆盖(可补测)。"}, "plane_list_stickies": {"A", "仅需 slug(有样本),驱动未覆盖(可补测)。"}, // —— A:需 localPath(可自造)—— "storage_file_upload": {"A", "缺 localPath。测试可自造一个本地临时文件再上传(无需外部样本)。"}, "storage_file_read_to_local": {"A", "缺 path。可用 L3RW 写入的 path 级联补测。"}, } // writeSkipAnalysis 渲染当前仍为 SKIP、且未在其它阶段 PASS 的工具的逐条说明。 func writeSkipAnalysis(b *strings.Builder) { passed := map[string]bool{} for _, r := range results { if r.status == PASS { passed[r.tool] = true } } // 收集仍 SKIP 且从未 PASS 的工具(去重) seen := map[string]bool{} type row struct{ tool, group, need string } var rows []row for _, r := range results { if r.status != SKIP || passed[r.tool] || seen[r.tool] { continue } seen[r.tool] = true n, ok := skipNeeds[r.tool] if !ok { n = skipNeed{"A", "需必填参数样本(未归类,可级联或提供样本补测)"} } rows = append(rows, row{r.tool, n.group, n.need}) } b.WriteString(fmt.Sprintf("以下为**排除已在其它阶段 PASS 后**、仍未覆盖的 %d 个工具。已在别处 PASS 的工具(如 service_site_query_info、plane_list_* 等)不再列出。\n\n", len(rows))) b.WriteString("分组:A=可级联/自造补测(无需外部样本)|B=需“确有数据”的样本|C=需环境或代码改动|D=环境数据为空。\n\n") for _, g := range []string{"A", "B", "C", "D"} { var gr []row for _, r := range rows { if r.group == g { gr = append(gr, r) } } if len(gr) == 0 { continue } labels := map[string]string{ "A": "A 组 · 可级联/自造补测(无需外部样本)", "B": "B 组 · 需“确有数据”的样本(要你提供)", "C": "C 组 · 需环境或代码改动", "D": "D 组 · 环境数据为空(非缺陷,需造/等数据)", } b.WriteString(fmt.Sprintf("### %s\n\n| 工具 | 缺什么 / 如何补 |\n|------|------|\n", labels[g])) for _, r := range gr { need := strings.ReplaceAll(r.need, "|", "\\|") b.WriteString(fmt.Sprintf("| `%s` | %s |\n", r.tool, need)) } b.WriteString("\n") } } func writePiiReport(path string) { var b strings.Builder b.WriteString("# FWH MCP Server 敏感数据脱敏专项报告 (L4)\n\n") b.WriteString(fmt.Sprintf("生成时间: %s\n\n", time.Now().Format("2006-01-02 15:04:05"))) b.WriteString("说明: 负向断言=返回内容不得出现明文 PII(手机号/邮箱/IMEI/MAC/IP);正向断言=保留字段(sn/giccid等)应保持原值。\n") b.WriteString("L4-0 单元测试 (go test ./cmd/mcp/tools/helper/...) 已单独执行。\n\n") pp, pf, ps := 0, 0, 0 for _, f := range piis { switch f.status { case PASS: pp++ case FAIL: pf++ case SKIP: ps++ } } b.WriteString(fmt.Sprintf("端到端脱敏用例: PASS %d | FAIL %d | SKIP %d\n\n", pp, pf, ps)) for _, f := range piis { b.WriteString(fmt.Sprintf("## %s — %s [%s]\n\n", f.id, f.tool, f.status)) if len(f.leaks) > 0 { b.WriteString("**检出明文 PII(安全缺陷):**\n") for _, l := range f.leaks { b.WriteString("- " + l + "\n") } b.WriteString("\n") } else { b.WriteString("负向断言: 无明文 PII\n\n") } b.WriteString("正向断言: " + f.positive + "\n\n") if f.sample != "" { b.WriteString("脱敏后样本片段:\n```\n" + f.sample + "\n```\n\n") } } os.WriteFile(path, []byte(b.String()), 0644) }