agentmcp_test.go 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  1. package agentmcp
  2. import (
  3. "encoding/json"
  4. "regexp"
  5. "testing"
  6. "unicode"
  7. "fwh-toolbox-server/agentmcp/kit"
  8. )
  9. // 约束测试(agent-mcp.md 3.5):名称、标题、描述、读写声明、schema 类型。
  10. var nameRE = regexp.MustCompile(`^[a-z][a-z0-9_]{2,39}$`)
  11. // 引擎侧完整名称 mcp__gateway__<服务名>__<工具名> 不能超过 64;服务名最长按 6 个字符(fwhops)计算
  12. const enginePrefixLen = len("mcp__gateway__") + len("fwhops") + len("__")
  13. func TestToolConstraints(t *testing.T) {
  14. seen := map[string]string{}
  15. for _, set := range []string{SetReadOnly, SetOps} {
  16. list, err := Tools(set)
  17. if err != nil {
  18. t.Fatal(err)
  19. }
  20. if len(list) == 0 {
  21. t.Fatalf("工具集 %s 为空", set)
  22. }
  23. for _, r := range list {
  24. m := r.Meta()
  25. if !nameRE.MatchString(m.Name) {
  26. t.Errorf("%s: 名称不符合 ^[a-z][a-z0-9_]{2,39}$", m.Name)
  27. }
  28. if enginePrefixLen+len(m.Name) > 64 {
  29. t.Errorf("%s: 引擎侧完整名称超过 64 字符", m.Name)
  30. }
  31. if prev, dup := seen[m.Name]; dup {
  32. t.Errorf("%s: 与工具集 %s 中的工具重名", m.Name, prev)
  33. }
  34. seen[m.Name] = set
  35. if m.Title == "" {
  36. t.Errorf("%s: 缺少 title", m.Name)
  37. }
  38. if !hasHan(m.Description) {
  39. t.Errorf("%s: 描述应为中文", m.Name)
  40. }
  41. if set == SetReadOnly && !m.ReadOnly {
  42. t.Errorf("%s: readonly 工具集中出现写工具", m.Name)
  43. }
  44. if set == SetOps && m.ReadOnly {
  45. t.Errorf("%s: ops 工具集中出现只读工具", m.Name)
  46. }
  47. tool := r.Tool()
  48. if tool.Annotations.ReadOnlyHint == nil {
  49. t.Errorf("%s: 未显式声明 readOnlyHint", m.Name)
  50. } else if *tool.Annotations.ReadOnlyHint != m.ReadOnly {
  51. t.Errorf("%s: readOnlyHint 与定义不一致", m.Name)
  52. }
  53. if tool.Annotations.Title != m.Title {
  54. t.Errorf("%s: title 注解与定义不一致", m.Name)
  55. }
  56. b, err := json.Marshal(tool)
  57. if err != nil {
  58. t.Fatalf("%s: 序列化失败: %v", m.Name, err)
  59. }
  60. var decoded struct {
  61. InputSchema map[string]any `json:"inputSchema"`
  62. }
  63. if err := json.Unmarshal(b, &decoded); err != nil {
  64. t.Fatalf("%s: 解析失败: %v", m.Name, err)
  65. }
  66. if decoded.InputSchema["type"] != "object" {
  67. t.Errorf("%s: inputSchema.type 应为 object,实际 %v", m.Name, decoded.InputSchema["type"])
  68. }
  69. }
  70. }
  71. }
  72. // 一期清单(agent-mcp.md 第 4 节)全部注册
  73. func TestReadOnlyToolList(t *testing.T) {
  74. want := []string{
  75. "site_search", "site_get", "site_power_flow", "site_power_flow_export",
  76. "alarm_current", "alarm_events", "alarm_snapshot", "alarm_stats", "alarm_sites_by_code",
  77. "shutdown_stats", "shutdown_sites",
  78. "mqtt_log_search", "ems_log_records", "update_log_system", "update_log_ahub",
  79. "fault_record_list", "fault_record_detail", "fault_record_callable", "fault_record_call_history",
  80. "tou_template_list", "tou_template_get",
  81. "sim_get", "sim_search", "sim_usage_monthly",
  82. "ems_param_lookup", "plane_issue_search", "plane_issue_get", "sn_parse",
  83. }
  84. list, _ := Tools(SetReadOnly)
  85. got := map[string]bool{}
  86. for _, r := range list {
  87. got[r.Meta().Name] = true
  88. }
  89. for _, n := range want {
  90. if !got[n] {
  91. t.Errorf("缺少工具 %s", n)
  92. }
  93. }
  94. if len(got) != len(want) {
  95. t.Errorf("只读工具数量 %d,期望 %d", len(got), len(want))
  96. }
  97. }
  98. func TestExportTimeoutByName(t *testing.T) {
  99. if !(kit.Meta{Name: "site_power_flow_export"}).IsExport() {
  100. t.Error("site_power_flow_export 应识别为导出工具")
  101. }
  102. if (kit.Meta{Name: "site_power_flow"}).IsExport() {
  103. t.Error("site_power_flow 不是导出工具")
  104. }
  105. }
  106. func hasHan(s string) bool {
  107. for _, r := range s {
  108. if unicode.Is(unicode.Han, r) {
  109. return true
  110. }
  111. }
  112. return false
  113. }