// Command runner 是 FWH MCP Server 的非写操作接口测试驱动。 // // 它通过 mcp-go 的 stdio client 拉起被测二进制(cwd 设为运行路径以加载 config.mcp.env), // 执行 L0(协议/清单)、L1(参数校验)、L2(只读冒烟)、L4(敏感数据脱敏)用例, // 三态判定 PASS/FAIL/SKIP,并生成 report.md 与 pii_report.md。 // // 用法: // // go run ./cmd/mcp/test/runner \ // -bin /tmp/fwh-mcp-server-new \ // -cwd /var/www/fwh-toolbox/server \ // -out /var/www/fwh-toolbox/server package main import ( "context" "flag" "fmt" "os" "os/exec" "regexp" "strings" "time" "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/client/transport" "github.com/mark3labs/mcp-go/mcp" ) // ---- 结果模型 ---- type status string const ( PASS status = "PASS" FAIL status = "FAIL" SKIP status = "SKIP" ) type result struct { id string tool string level string status status elapsed time.Duration note string } type piiFinding struct { id string tool string status status leaks []string // 命中的明文 PII(应为空) positive string // 正向断言结果 sample string // 脱敏后样本片段 } var ( results []result piis []piiFinding ) func addResult(r result) { results = append(results, r) } // truncate 按字节安全截断(rune 边界),用于报告展示。 func truncate(s string, max int) string { if len(s) <= max { return s } end := max for end > 0 && s[end]&0xC0 == 0x80 { end-- } return s[:end] + "…" } // ---- 明文 PII 检测正则 ---- var ( rePhone = regexp.MustCompile(`(^|[^\d.])1\d{10}([^\d.]|$)`) reImei = regexp.MustCompile(`(^|[^\d.])\d{14,15}([^\d.]|$)`) reMac = regexp.MustCompile(`([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}`) reIP = regexp.MustCompile(`\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b`) reEmail = regexp.MustCompile(`[A-Za-z0-9._%+\-]{2,}@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`) ) // commonSubnetMasks 常见子网掩码/网络地址,非 PII,需从 IP 检测中排除。 var commonSubnetMasks = map[string]bool{ "255.255.255.0": true, "255.255.255.255": true, "255.255.0.0": true, "255.0.0.0": true, "0.0.0.0": true, "255.255.255.128": true, "255.255.255.192": true, "255.255.254.0": true, "255.255.252.0": true, "127.0.0.1": true, } // detectLeaks 返回文本中命中的明文 PII 说明列表(空=无泄露)。 // 已脱敏的掩码串 ****** / x***@ 不会命中。 // 排除误报来源:子网掩码、浮点数内部数字串(正则用 [^\d.] 边界)、被脱敏字段值。 func detectLeaks(text string) []string { var leaks []string if m := rePhone.FindString(text); m != "" { leaks = append(leaks, "手机号明文:"+strings.TrimSpace(m)) } if m := reImei.FindString(text); m != "" { leaks = append(leaks, "IMEI/IMSI明文:"+strings.TrimSpace(m)) } if m := reMac.FindString(text); m != "" { leaks = append(leaks, "MAC明文:"+m) } for _, m := range reEmail.FindAllString(text, -1) { if !strings.Contains(m, "*") { leaks = append(leaks, "邮箱明文:"+m) break } } // IP:排除子网掩码/回环地址;排除 "subnet" 字段值(子网掩码非 PII,且未在脱敏规则内) for _, loc := range reIP.FindAllStringIndex(text, -1) { m := text[loc[0]:loc[1]] if commonSubnetMasks[m] || isSubnetMask(m) { continue } // 检查该值前 ~30 字节是否为 "subnet" 键 ctxStart := loc[0] - 30 if ctxStart < 0 { ctxStart = 0 } if strings.Contains(text[ctxStart:loc[0]], "subnet") { continue } leaks = append(leaks, "IP明文:"+m) break } return leaks } // isSubnetMask 判断点分四段是否为合法子网掩码(连续高位1)。 func isSubnetMask(s string) bool { parts := strings.Split(s, ".") if len(parts) != 4 { return false } var v uint32 for _, p := range parts { n := 0 for _, ch := range p { if ch < '0' || ch > '9' { return false } n = n*10 + int(ch-'0') } if n > 255 { return false } v = v<<8 | uint32(n) } if v == 0 { return true } // 取反后 +1 应为 2 的幂 → 连续掩码 inv := ^v + 1 return inv&(inv-1) == 0 } // ---- 调用辅助 ---- func callText(ctx context.Context, c *client.Client, name string, args map[string]any) (text string, isErr bool, callErr error) { req := mcp.CallToolRequest{} req.Params.Name = name req.Params.Arguments = args cctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() res, err := c.CallTool(cctx, req) if err != nil { return "", false, err } var sb strings.Builder for _, ct := range res.Content { if tc, ok := mcp.AsTextContent(ct); ok { sb.WriteString(tc.Text) } } return sb.String(), res.IsError, nil } func main() { bin := flag.String("bin", "/tmp/fwh-mcp-server-new", "被测 MCP server 二进制路径") cwd := flag.String("cwd", "/var/www/fwh-toolbox/server", "server 工作目录(加载 config.mcp.env)") out := flag.String("out", "/var/www/fwh-toolbox/server", "报告输出目录") flag.Parse() ctx := context.Background() // 用 WithCommandFunc 设置子进程 cwd 为运行路径 cmdFunc := func(_ context.Context, command string, env []string, args []string) (*exec.Cmd, error) { cmd := exec.Command(command, args...) cmd.Dir = *cwd cmd.Env = append(os.Environ(), env...) return cmd, nil } c, err := client.NewStdioMCPClientWithOptions(*bin, nil, nil, transport.WithCommandFunc(cmdFunc)) if err != nil { fmt.Fprintf(os.Stderr, "启动 MCP server 失败: %v\n", err) os.Exit(1) } defer c.Close() // ---- L0-1 初始化握手 ---- initReq := mcp.InitializeRequest{} initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION initReq.Params.ClientInfo = mcp.Implementation{Name: "mcp-test-runner", Version: "1.0.0"} ictx, icancel := context.WithTimeout(ctx, 30*time.Second) defer icancel() t0 := time.Now() initRes, err := c.Initialize(ictx, initReq) if err != nil { addResult(result{"L0-1", "-", "L0", FAIL, time.Since(t0), "initialize 失败: " + err.Error()}) writeReports(*out) fmt.Fprintf(os.Stderr, "initialize 失败: %v\n", err) os.Exit(1) } { note := fmt.Sprintf("name=%s version=%s proto=%s", initRes.ServerInfo.Name, initRes.ServerInfo.Version, initRes.ProtocolVersion) st := PASS if initRes.ServerInfo.Name != "fwh-tools" || initRes.ServerInfo.Version != "2.0.0" { st = FAIL } addResult(result{"L0-1", "-", "L0", st, time.Since(t0), note}) } // ---- L0-2 工具清单 ---- t0 = time.Now() ltRes, err := c.ListTools(ctx, mcp.ListToolsRequest{}) if err != nil { addResult(result{"L0-2", "-", "L0", FAIL, time.Since(t0), "ListTools 失败: " + err.Error()}) writeReports(*out) os.Exit(1) } tools := ltRes.Tools toolByName := map[string]mcp.Tool{} for _, t := range tools { toolByName[t.Name] = t } addResult(result{"L0-2", "-", "L0", PASS, time.Since(t0), fmt.Sprintf("共 %d 个工具", len(tools))}) // ---- L0-3 命名规范 ---- { allowedPrefix := []string{"service_", "data_", "storage_", "util_", "plane_"} var bad []string for _, t := range tools { ok := false for _, p := range allowedPrefix { if strings.HasPrefix(t.Name, p) { ok = true break } } if !ok { bad = append(bad, t.Name) } } st, note := PASS, "全部前缀合法" if len(bad) > 0 { st, note = FAIL, "非法前缀: "+strings.Join(bad, ",") } addResult(result{"L0-3", "-", "L0", st, 0, note}) } // ---- L0-4 schema 完整性 ---- { var bad []string for _, t := range tools { if t.Description == "" { bad = append(bad, t.Name+"(无描述)") } props := t.InputSchema.Properties for _, reqf := range t.InputSchema.Required { if _, ok := props[reqf]; !ok { bad = append(bad, fmt.Sprintf("%s(required %s 不在 properties)", t.Name, reqf)) } } } st, note := PASS, "schema 完整" if len(bad) > 0 { st, note = FAIL, strings.Join(bad, "; ") } addResult(result{"L0-4", "-", "L0", st, 0, note}) } // ---- L0-5 未知工具 ---- { t0 := time.Now() _, isErr, cErr := callText(ctx, c, "__no_such_tool__", map[string]any{}) // 期望:协议 error 或 isError=true st, note := PASS, "正确拒绝未知工具" if cErr == nil && !isErr { st, note = FAIL, "未知工具未被拒绝" } addResult(result{"L0-5", "__no_such_tool__", "L0", st, time.Since(t0), note}) } // ---- L1 参数校验 ---- runL1(ctx, c) // ---- L2 只读冒烟 ---- runL2(ctx, c, tools, toolByName) // ---- L4 敏感数据脱敏 ---- runL4(ctx, c, toolByName) // ---- L2S 真实样本补测 + L3RW MinIO/文件读写闭环 ---- runSampleCases(ctx, c, toolByName) runWriteLoop(ctx, c, toolByName) // ---- L2C 级联补测(从列表接口提取 id/key 再喂给详情接口)---- runCascadeCases(ctx, c, toolByName) // ---- L2X 额外补测(递归级联 / 自造文件)---- runExtraCascades(ctx, c, toolByName) writeReports(*out) fmt.Println("测试完成,报告已写入:", *out) } // 真实样本(由用户提供) const ( sampleSN = "10130095A00X25280030" sampleGICCID = "8931084524121848556" samplePlaneSlug = "fwh" samplePlaneProject = "55efdd5f-214d-43b3-bed5-a6b71bd04673" )