| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331 |
- // 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"
- )
|