helper.go 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. package helper
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "reflect"
  6. "github.com/mark3labs/mcp-go/mcp"
  7. )
  8. // GetStringSlice 从请求参数中读取字符串数组(结构化数组 schema,如 WithArray+WithStringItems)。
  9. // - 参数缺失或为 nil:返回 (nil, false)
  10. // - 参数为 []any:逐元素转字符串(仅接受 string 元素,遇到非字符串元素返回 ok=false)
  11. // - 其余类型:返回 ok=false
  12. //
  13. // 注意:本函数不接受 JSON 字符串形式的入参,参数应通过结构化数组 schema 传入。
  14. func GetStringSlice(req mcp.CallToolRequest, key string) ([]string, bool) {
  15. args := req.GetArguments()
  16. raw, exists := args[key]
  17. if !exists || raw == nil {
  18. return nil, false
  19. }
  20. arr, ok := raw.([]any)
  21. if !ok {
  22. return nil, false
  23. }
  24. out := make([]string, 0, len(arr))
  25. for _, item := range arr {
  26. s, ok := item.(string)
  27. if !ok {
  28. return nil, false
  29. }
  30. out = append(out, s)
  31. }
  32. return out, true
  33. }
  34. // JSONResult 将任意数据序列化为 JSON 文本返回,序列化前完成 PII 脱敏(A+B)。
  35. // 脱敏失败时返回错误,拒绝输出(fail-closed)。
  36. func JSONResult(data any) *mcp.CallToolResult {
  37. b, err := marshalSanitized(data)
  38. if err != nil {
  39. return mcp.NewToolResultError(err.Error())
  40. }
  41. return mcp.NewToolResultText(string(b))
  42. }
  43. // TextResult 直接返回文本
  44. func TextResult(text string) *mcp.CallToolResult {
  45. return mcp.NewToolResultText(text)
  46. }
  47. // ParamError 参数缺失错误
  48. func ParamError(name string) *mcp.CallToolResult {
  49. return mcp.NewToolResultError(fmt.Sprintf("参数 %s 必填", name))
  50. }
  51. // ParamInvalidError 参数格式错误
  52. func ParamInvalidError(name string, detail string) *mcp.CallToolResult {
  53. return mcp.NewToolResultError(fmt.Sprintf("参数 %s 格式错误: %s", name, detail))
  54. }
  55. // ServiceError 业务调用错误
  56. func ServiceError(action string, err error) *mcp.CallToolResult {
  57. return mcp.NewToolResultError(fmt.Sprintf("%s失败: %v", action, err))
  58. }
  59. // BatchValidationError 批量校验错误:返回结构化的 valid/invalid 分离结果,
  60. // 便于 Agent 精确定位哪些输入非法并只修正这些项,而无需重猜整批。
  61. // reason 为对非法项的统一说明(如 "SN 格式非法")。
  62. func BatchValidationError(action string, valid []string, invalid []string, reason string) *mcp.CallToolResult {
  63. return JSONResult(map[string]any{
  64. "error": true,
  65. "action": action,
  66. "reason": reason,
  67. "valid": valid,
  68. "invalid": invalid,
  69. "note": "invalid 中的项未通过校验,请修正后重试;valid 为已通过校验的项。",
  70. })
  71. }
  72. // EmptyResult 空结果
  73. func EmptyResult(msg string) *mcp.CallToolResult {
  74. return mcp.NewToolResultText(msg)
  75. }
  76. // PageResult 将分页查询结果包装为 AI 友好的结构:分页元信息
  77. // total(符合条件的总数)、pageSize、pageNum、returned(本页返回条数)与
  78. // hasMore(是否还有下一页)直接返回,便于 Agent 判断是否翻完、如何继续翻页;
  79. // data 正文(带数据)经 PII 脱敏后无条件落盘,只在返回里给出 localPath 与 summary
  80. // (不含数据正文),避免分页数据进入模型上下文。
  81. //
  82. // hasMore = pageNum*pageSize < total(pageNum 从 1 开始)。
  83. // hint 用于落盘文件名前缀。
  84. func PageResult(total, pageSize, pageNum uint, data any, hint string) *mcp.CallToolResult {
  85. returned := 0
  86. // 尽力统计本页条数:data 通常是切片
  87. switch d := data.(type) {
  88. case nil:
  89. returned = 0
  90. default:
  91. if arr, ok := toSliceLen(d); ok {
  92. returned = arr
  93. }
  94. }
  95. hasMore := uint(pageNum)*uint(pageSize) < uint(total)
  96. meta := map[string]any{
  97. "total": total,
  98. "pageSize": pageSize,
  99. "pageNum": pageNum,
  100. "returned": returned,
  101. "hasMore": hasMore,
  102. }
  103. // data 正文无条件落盘:脱敏 → 写文件 → 只回 localPath + summary。
  104. if returned == 0 {
  105. // 本页无数据:无需落盘,直接标注空数据。
  106. meta["data"] = nil
  107. meta["note"] = "本页无数据。"
  108. } else if b, err := marshalSanitized(data); err != nil {
  109. return mcp.NewToolResultError(err.Error())
  110. } else if path, werr := writeSpillFile(hint, b); werr != nil {
  111. return mcp.NewToolResultError(fmt.Sprintf("分页数据落盘失败: %v", werr))
  112. } else {
  113. dataMeta := map[string]any{
  114. "localPath": path,
  115. "bytes": len(b),
  116. }
  117. if summary := summarizeJSON(b); summary != nil {
  118. dataMeta["summary"] = summary
  119. }
  120. meta["data"] = dataMeta
  121. meta["note"] = "本页数据已写入本地文件,请使用文件读取工具按 data.localPath 读取完整内容。"
  122. }
  123. mb, err := json.MarshalIndent(meta, "", " ")
  124. if err != nil {
  125. return mcp.NewToolResultError(fmt.Sprintf("分页元信息序列化失败: %v", err))
  126. }
  127. return mcp.NewToolResultText(string(mb))
  128. }
  129. // toSliceLen 若 v 是切片或数组,返回其长度与 true;否则返回 (0,false)。
  130. func toSliceLen(v any) (int, bool) {
  131. rv := reflect.ValueOf(v)
  132. switch rv.Kind() {
  133. case reflect.Slice, reflect.Array:
  134. return rv.Len(), true
  135. default:
  136. return 0, false
  137. }
  138. }