main.go 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. // Command runner 是 FWH MCP Server 的非写操作接口测试驱动。
  2. //
  3. // 它通过 mcp-go 的 stdio client 拉起被测二进制(cwd 设为运行路径以加载 config.mcp.env),
  4. // 执行 L0(协议/清单)、L1(参数校验)、L2(只读冒烟)、L4(敏感数据脱敏)用例,
  5. // 三态判定 PASS/FAIL/SKIP,并生成 report.md 与 pii_report.md。
  6. //
  7. // 用法:
  8. //
  9. // go run ./cmd/mcp/test/runner \
  10. // -bin /tmp/fwh-mcp-server-new \
  11. // -cwd /var/www/fwh-toolbox/server \
  12. // -out /var/www/fwh-toolbox/server
  13. package main
  14. import (
  15. "context"
  16. "flag"
  17. "fmt"
  18. "os"
  19. "os/exec"
  20. "regexp"
  21. "strings"
  22. "time"
  23. "github.com/mark3labs/mcp-go/client"
  24. "github.com/mark3labs/mcp-go/client/transport"
  25. "github.com/mark3labs/mcp-go/mcp"
  26. )
  27. // ---- 结果模型 ----
  28. type status string
  29. const (
  30. PASS status = "PASS"
  31. FAIL status = "FAIL"
  32. SKIP status = "SKIP"
  33. )
  34. type result struct {
  35. id string
  36. tool string
  37. level string
  38. status status
  39. elapsed time.Duration
  40. note string
  41. }
  42. type piiFinding struct {
  43. id string
  44. tool string
  45. status status
  46. leaks []string // 命中的明文 PII(应为空)
  47. positive string // 正向断言结果
  48. sample string // 脱敏后样本片段
  49. }
  50. var (
  51. results []result
  52. piis []piiFinding
  53. )
  54. func addResult(r result) { results = append(results, r) }
  55. // truncate 按字节安全截断(rune 边界),用于报告展示。
  56. func truncate(s string, max int) string {
  57. if len(s) <= max {
  58. return s
  59. }
  60. end := max
  61. for end > 0 && s[end]&0xC0 == 0x80 {
  62. end--
  63. }
  64. return s[:end] + "…"
  65. }
  66. // ---- 明文 PII 检测正则 ----
  67. var (
  68. rePhone = regexp.MustCompile(`(^|[^\d.])1\d{10}([^\d.]|$)`)
  69. reImei = regexp.MustCompile(`(^|[^\d.])\d{14,15}([^\d.]|$)`)
  70. reMac = regexp.MustCompile(`([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}`)
  71. reIP = regexp.MustCompile(`\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b`)
  72. reEmail = regexp.MustCompile(`[A-Za-z0-9._%+\-]{2,}@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`)
  73. )
  74. // commonSubnetMasks 常见子网掩码/网络地址,非 PII,需从 IP 检测中排除。
  75. var commonSubnetMasks = map[string]bool{
  76. "255.255.255.0": true, "255.255.255.255": true, "255.255.0.0": true,
  77. "255.0.0.0": true, "0.0.0.0": true, "255.255.255.128": true,
  78. "255.255.255.192": true, "255.255.254.0": true, "255.255.252.0": true,
  79. "127.0.0.1": true,
  80. }
  81. // detectLeaks 返回文本中命中的明文 PII 说明列表(空=无泄露)。
  82. // 已脱敏的掩码串 ****** / x***@ 不会命中。
  83. // 排除误报来源:子网掩码、浮点数内部数字串(正则用 [^\d.] 边界)、被脱敏字段值。
  84. func detectLeaks(text string) []string {
  85. var leaks []string
  86. if m := rePhone.FindString(text); m != "" {
  87. leaks = append(leaks, "手机号明文:"+strings.TrimSpace(m))
  88. }
  89. if m := reImei.FindString(text); m != "" {
  90. leaks = append(leaks, "IMEI/IMSI明文:"+strings.TrimSpace(m))
  91. }
  92. if m := reMac.FindString(text); m != "" {
  93. leaks = append(leaks, "MAC明文:"+m)
  94. }
  95. for _, m := range reEmail.FindAllString(text, -1) {
  96. if !strings.Contains(m, "*") {
  97. leaks = append(leaks, "邮箱明文:"+m)
  98. break
  99. }
  100. }
  101. // IP:排除子网掩码/回环地址;排除 "subnet" 字段值(子网掩码非 PII,且未在脱敏规则内)
  102. for _, loc := range reIP.FindAllStringIndex(text, -1) {
  103. m := text[loc[0]:loc[1]]
  104. if commonSubnetMasks[m] || isSubnetMask(m) {
  105. continue
  106. }
  107. // 检查该值前 ~30 字节是否为 "subnet" 键
  108. ctxStart := loc[0] - 30
  109. if ctxStart < 0 {
  110. ctxStart = 0
  111. }
  112. if strings.Contains(text[ctxStart:loc[0]], "subnet") {
  113. continue
  114. }
  115. leaks = append(leaks, "IP明文:"+m)
  116. break
  117. }
  118. return leaks
  119. }
  120. // isSubnetMask 判断点分四段是否为合法子网掩码(连续高位1)。
  121. func isSubnetMask(s string) bool {
  122. parts := strings.Split(s, ".")
  123. if len(parts) != 4 {
  124. return false
  125. }
  126. var v uint32
  127. for _, p := range parts {
  128. n := 0
  129. for _, ch := range p {
  130. if ch < '0' || ch > '9' {
  131. return false
  132. }
  133. n = n*10 + int(ch-'0')
  134. }
  135. if n > 255 {
  136. return false
  137. }
  138. v = v<<8 | uint32(n)
  139. }
  140. if v == 0 {
  141. return true
  142. }
  143. // 取反后 +1 应为 2 的幂 → 连续掩码
  144. inv := ^v + 1
  145. return inv&(inv-1) == 0
  146. }
  147. // ---- 调用辅助 ----
  148. func callText(ctx context.Context, c *client.Client, name string, args map[string]any) (text string, isErr bool, callErr error) {
  149. req := mcp.CallToolRequest{}
  150. req.Params.Name = name
  151. req.Params.Arguments = args
  152. cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
  153. defer cancel()
  154. res, err := c.CallTool(cctx, req)
  155. if err != nil {
  156. return "", false, err
  157. }
  158. var sb strings.Builder
  159. for _, ct := range res.Content {
  160. if tc, ok := mcp.AsTextContent(ct); ok {
  161. sb.WriteString(tc.Text)
  162. }
  163. }
  164. return sb.String(), res.IsError, nil
  165. }
  166. func main() {
  167. bin := flag.String("bin", "/tmp/fwh-mcp-server-new", "被测 MCP server 二进制路径")
  168. cwd := flag.String("cwd", "/var/www/fwh-toolbox/server", "server 工作目录(加载 config.mcp.env)")
  169. out := flag.String("out", "/var/www/fwh-toolbox/server", "报告输出目录")
  170. flag.Parse()
  171. ctx := context.Background()
  172. // 用 WithCommandFunc 设置子进程 cwd 为运行路径
  173. cmdFunc := func(_ context.Context, command string, env []string, args []string) (*exec.Cmd, error) {
  174. cmd := exec.Command(command, args...)
  175. cmd.Dir = *cwd
  176. cmd.Env = append(os.Environ(), env...)
  177. return cmd, nil
  178. }
  179. c, err := client.NewStdioMCPClientWithOptions(*bin, nil, nil, transport.WithCommandFunc(cmdFunc))
  180. if err != nil {
  181. fmt.Fprintf(os.Stderr, "启动 MCP server 失败: %v\n", err)
  182. os.Exit(1)
  183. }
  184. defer c.Close()
  185. // ---- L0-1 初始化握手 ----
  186. initReq := mcp.InitializeRequest{}
  187. initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
  188. initReq.Params.ClientInfo = mcp.Implementation{Name: "mcp-test-runner", Version: "1.0.0"}
  189. ictx, icancel := context.WithTimeout(ctx, 30*time.Second)
  190. defer icancel()
  191. t0 := time.Now()
  192. initRes, err := c.Initialize(ictx, initReq)
  193. if err != nil {
  194. addResult(result{"L0-1", "-", "L0", FAIL, time.Since(t0), "initialize 失败: " + err.Error()})
  195. writeReports(*out)
  196. fmt.Fprintf(os.Stderr, "initialize 失败: %v\n", err)
  197. os.Exit(1)
  198. }
  199. {
  200. note := fmt.Sprintf("name=%s version=%s proto=%s",
  201. initRes.ServerInfo.Name, initRes.ServerInfo.Version, initRes.ProtocolVersion)
  202. st := PASS
  203. if initRes.ServerInfo.Name != "fwh-tools" || initRes.ServerInfo.Version != "2.0.0" {
  204. st = FAIL
  205. }
  206. addResult(result{"L0-1", "-", "L0", st, time.Since(t0), note})
  207. }
  208. // ---- L0-2 工具清单 ----
  209. t0 = time.Now()
  210. ltRes, err := c.ListTools(ctx, mcp.ListToolsRequest{})
  211. if err != nil {
  212. addResult(result{"L0-2", "-", "L0", FAIL, time.Since(t0), "ListTools 失败: " + err.Error()})
  213. writeReports(*out)
  214. os.Exit(1)
  215. }
  216. tools := ltRes.Tools
  217. toolByName := map[string]mcp.Tool{}
  218. for _, t := range tools {
  219. toolByName[t.Name] = t
  220. }
  221. addResult(result{"L0-2", "-", "L0", PASS, time.Since(t0), fmt.Sprintf("共 %d 个工具", len(tools))})
  222. // ---- L0-3 命名规范 ----
  223. {
  224. allowedPrefix := []string{"service_", "data_", "storage_", "util_", "plane_"}
  225. var bad []string
  226. for _, t := range tools {
  227. ok := false
  228. for _, p := range allowedPrefix {
  229. if strings.HasPrefix(t.Name, p) {
  230. ok = true
  231. break
  232. }
  233. }
  234. if !ok {
  235. bad = append(bad, t.Name)
  236. }
  237. }
  238. st, note := PASS, "全部前缀合法"
  239. if len(bad) > 0 {
  240. st, note = FAIL, "非法前缀: "+strings.Join(bad, ",")
  241. }
  242. addResult(result{"L0-3", "-", "L0", st, 0, note})
  243. }
  244. // ---- L0-4 schema 完整性 ----
  245. {
  246. var bad []string
  247. for _, t := range tools {
  248. if t.Description == "" {
  249. bad = append(bad, t.Name+"(无描述)")
  250. }
  251. props := t.InputSchema.Properties
  252. for _, reqf := range t.InputSchema.Required {
  253. if _, ok := props[reqf]; !ok {
  254. bad = append(bad, fmt.Sprintf("%s(required %s 不在 properties)", t.Name, reqf))
  255. }
  256. }
  257. }
  258. st, note := PASS, "schema 完整"
  259. if len(bad) > 0 {
  260. st, note = FAIL, strings.Join(bad, "; ")
  261. }
  262. addResult(result{"L0-4", "-", "L0", st, 0, note})
  263. }
  264. // ---- L0-5 未知工具 ----
  265. {
  266. t0 := time.Now()
  267. _, isErr, cErr := callText(ctx, c, "__no_such_tool__", map[string]any{})
  268. // 期望:协议 error 或 isError=true
  269. st, note := PASS, "正确拒绝未知工具"
  270. if cErr == nil && !isErr {
  271. st, note = FAIL, "未知工具未被拒绝"
  272. }
  273. addResult(result{"L0-5", "__no_such_tool__", "L0", st, time.Since(t0), note})
  274. }
  275. // ---- L1 参数校验 ----
  276. runL1(ctx, c)
  277. // ---- L2 只读冒烟 ----
  278. runL2(ctx, c, tools, toolByName)
  279. // ---- L4 敏感数据脱敏 ----
  280. runL4(ctx, c, toolByName)
  281. // ---- L2S 真实样本补测 + L3RW MinIO/文件读写闭环 ----
  282. runSampleCases(ctx, c, toolByName)
  283. runWriteLoop(ctx, c, toolByName)
  284. // ---- L2C 级联补测(从列表接口提取 id/key 再喂给详情接口)----
  285. runCascadeCases(ctx, c, toolByName)
  286. // ---- L2X 额外补测(递归级联 / 自造文件)----
  287. runExtraCascades(ctx, c, toolByName)
  288. writeReports(*out)
  289. fmt.Println("测试完成,报告已写入:", *out)
  290. }
  291. // 真实样本(由用户提供)
  292. const (
  293. sampleSN = "10130095A00X25280030"
  294. sampleGICCID = "8931084524121848556"
  295. samplePlaneSlug = "fwh"
  296. samplePlaneProject = "55efdd5f-214d-43b3-bed5-a6b71bd04673"
  297. )