소스 검색

fix:按AgentServer MCP接入指南修正工具提示与错误信息

- kit.Def 新增 Destructive、Idempotent:tou_template_save 声明为破坏性(自动模式下也每次审批),site_info_refresh 声明为幂等
- 上游错误只返回数据源、错误类别与错误编号,原始错误(可能含内部地址、SQL)只写日志;site_get、plane_issue_get、ems_log_records 的部分失败同样处理

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 5 일 전
부모
커밋
7fb38af145

+ 2 - 2
docs/ai-assistant-agentserver/agent-mcp.md

@@ -86,7 +86,7 @@ func (d Def[T]) Register(s *server.MCPServer) {
             mcp.WithInputSchema[T](),
             mcp.WithTitleAnnotation(d.Title),
             mcp.WithReadOnlyHintAnnotation(d.ReadOnly),
-            mcp.WithDestructiveHintAnnotation(false),
+            mcp.WithDestructiveHintAnnotation(d.Destructive),   // 覆盖、删除类写操作为 true:自动模式下也每次审批
             mcp.WithOpenWorldHintAnnotation(false),
         ),
         mcp.NewTypedToolHandler(func(ctx context.Context, _ mcp.CallToolRequest, args T) (*mcp.CallToolResult, error) {
@@ -142,7 +142,7 @@ schema 由 mcp-go 依赖的 `google/jsonschema-go` 生成:`jsonschema` 标签
 | `List(rows, limit)` | 列表 | `{"total":N,"returned":n,"truncated":bool,"items":[...]}` |
 | `Table(cols, rows, limit)` | 行多、字段固定的数据 | 首行列名的 TSV 文本,比 JSON 省一半以上字符 |
 | `Export(name, csv)` | 导出工具(第 5 节) | `EmbeddedResource`(`text/csv`) |
-| `Fail(err)` / `Upstream(name, err)` | 错误 | `isError` + 一句中文说明 + 处理建议 |
+| `Fail(err)` / `Upstream(name, err)` | 错误 | `isError` + 一句中文说明 + 处理建议。`Upstream` 只返回数据源、错误类别与错误编号,原始错误(可能含内部地址、SQL)只写日志 |
 
 统一限制:单次结果(非导出)不超过 30,000 字符;超过时按行截断并标明 `truncated`,附一句“请缩小范围或使用 xxx_export”。这样不会触发 AgentServer 的 10 万字截断(那条路径会把前 10 万字仍放进上下文)。
 

+ 31 - 0
service/agentmcp/kit/errors_test.go

@@ -0,0 +1,31 @@
+package kit
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"strings"
+	"testing"
+
+	"go.mongodb.org/mongo-driver/mongo"
+)
+
+func TestUpstreamHidesRawError(t *testing.T) {
+	cases := []struct {
+		err  error
+		kind string
+	}{
+		{errors.New(`pq: relation "site_info" does not exist (host=10.0.0.5:5432)`), "数据源返回错误"},
+		{fmt.Errorf("query: %w", context.DeadlineExceeded), "请求超时"},
+		{fmt.Errorf("find: %w", mongo.ErrNoDocuments), "没有找到对应记录"},
+	}
+	for _, c := range cases {
+		text := UpstreamText("测试数据源", c.err)
+		if strings.Contains(text, "10.0.0.5") || strings.Contains(text, "relation") || strings.Contains(text, "mongo") {
+			t.Errorf("错误说明泄露了原始错误:%s", text)
+		}
+		if !strings.Contains(text, c.kind) || !strings.Contains(text, "错误编号") {
+			t.Errorf("错误说明应包含类别 %s 与错误编号:%s", c.kind, text)
+		}
+	}
+}

+ 38 - 1
service/agentmcp/kit/result.go

@@ -2,15 +2,21 @@ package kit
 
 import (
 	"bytes"
+	"context"
+	"crypto/rand"
 	"encoding/csv"
+	"encoding/hex"
 	"encoding/json"
 	"errors"
 	"fmt"
+	"net"
 	"reflect"
 	"strings"
 	"unicode/utf8"
 
 	"github.com/mark3labs/mcp-go/mcp"
+	"go.mongodb.org/mongo-driver/mongo"
+	"go.uber.org/zap"
 
 	"fwh-toolbox-server/utils/pii"
 )
@@ -40,7 +46,38 @@ func Failf(format string, a ...any) *mcp.CallToolResult {
 
 // Upstream 上游(FWH 平台、数据库、Plane 等)调用失败。
 func Upstream(source string, err error) *mcp.CallToolResult {
-	return mcp.NewToolResultError(fmt.Sprintf("调用%s失败:%v。可以稍后重试,或缩小查询范围。", source, err))
+	return mcp.NewToolResultError(UpstreamText(source, err) + "。可以稍后重试,或缩小查询范围。")
+}
+
+// UpstreamText 写给模型的上游错误说明:只给数据源、错误类别与错误编号。
+// 原始错误可能含内部地址、SQL、库表名,只写入日志(按错误编号检索),不返回给模型。
+func UpstreamText(source string, err error) string {
+	ref := errorRef()
+	zap.L().Warn("[agent-mcp] 上游调用失败", zap.String("source", source), zap.String("ref", ref), zap.Error(err))
+	return fmt.Sprintf("调用%s失败(%s,错误编号 %s)", source, errorKind(err), ref)
+}
+
+func errorKind(err error) string {
+	var ne net.Error
+	switch {
+	case errors.Is(err, context.DeadlineExceeded):
+		return "请求超时"
+	case errors.Is(err, context.Canceled):
+		return "请求已取消"
+	case errors.Is(err, mongo.ErrNoDocuments):
+		return "没有找到对应记录"
+	case errors.As(err, &ne) && ne.Timeout():
+		return "请求超时"
+	case errors.As(err, &ne):
+		return "网络连接失败"
+	}
+	return "数据源返回错误"
+}
+
+func errorRef() string {
+	b := make([]byte, 4)
+	_, _ = rand.Read(b)
+	return hex.EncodeToString(b)
 }
 
 // Empty 无数据时的统一返回(不是错误)。

+ 8 - 3
service/agentmcp/kit/tool.go

@@ -41,7 +41,12 @@ type Def[T any] struct {
 	Title       string // 中文短标题(审批卡片与后台显示)
 	Description string // 用途、适用场景、返回字段与单位
 	ReadOnly    bool
-	Handler     func(ctx context.Context, args T) (*mcp.CallToolResult, error)
+	// Destructive 覆盖、删除等不可恢复的写操作。AgentServer 据此“总是审批”:自动模式下也每次询问用户
+	Destructive bool
+	// Idempotent 同样参数重复调用结果相同(只读工具默认幂等)。AgentServer 网络出错时会自动重试一次,
+	// 所以有副作用且不去重的工具不能声明
+	Idempotent bool
+	Handler    func(ctx context.Context, args T) (*mcp.CallToolResult, error)
 	// Properties 可选:补充生成的 schema 中各字段的约束(枚举、取值范围、数组长度等),
 	// key 为字段名,value 合并进该字段的 schema(同名键覆盖)。服务端按补充后的 schema 校验参数。
 	Properties map[string]map[string]any
@@ -57,8 +62,8 @@ func (d Def[T]) Tool() mcp.Tool {
 		mcp.WithInputSchema[T](),
 		mcp.WithTitleAnnotation(d.Title),
 		mcp.WithReadOnlyHintAnnotation(d.ReadOnly),
-		mcp.WithDestructiveHintAnnotation(false),
-		mcp.WithIdempotentHintAnnotation(d.ReadOnly),
+		mcp.WithDestructiveHintAnnotation(d.Destructive),
+		mcp.WithIdempotentHintAnnotation(d.ReadOnly || d.Idempotent),
 		mcp.WithOpenWorldHintAnnotation(false),
 	)
 	if len(d.Properties) > 0 {

+ 19 - 0
service/agentmcp/kit/tool_test.go

@@ -55,3 +55,22 @@ func TestDefPropertiesUnknownField(t *testing.T) {
 	}()
 	Def[patchArgs]{Name: "patch_bad", Properties: map[string]map[string]any{"nope": {"type": "string"}}}.Tool()
 }
+
+func TestDefHints(t *testing.T) {
+	h := func(context.Context, NoArgs) (*mcp.CallToolResult, error) { return nil, nil }
+	cases := []struct {
+		def               Def[NoArgs]
+		destructive, idem bool
+	}{
+		{Def[NoArgs]{Name: "ro", ReadOnly: true, Handler: h}, false, true},
+		{Def[NoArgs]{Name: "write", Handler: h}, false, false},
+		{Def[NoArgs]{Name: "overwrite", Destructive: true, Handler: h}, true, false},
+		{Def[NoArgs]{Name: "dedup", Idempotent: true, Handler: h}, false, true},
+	}
+	for _, c := range cases {
+		a := c.def.Tool().Annotations
+		if *a.DestructiveHint != c.destructive || *a.IdempotentHint != c.idem {
+			t.Errorf("%s: destructive=%v idempotent=%v,期望 %v %v", c.def.Name, *a.DestructiveHint, *a.IdempotentHint, c.destructive, c.idem)
+		}
+	}
+}

+ 2 - 2
service/agentmcp/tools/logs.go

@@ -96,8 +96,8 @@ var EmsLogRecords = kit.Def[emsLogRecordsArgs]{
 		}
 		rec, err := repositories.QueryEmsLogCallRecord(sn, date, a.LogType)
 		if err != nil {
-			// 上游在"没有召唤日志"时也返回 error,这里按无数据处理并带上原因
-			return kit.Empty("未找到 EMS 日志召唤记录:" + err.Error()), nil
+			// 上游在"没有召唤日志"时也返回 error,这里按无数据处理;原始错误只写日志
+			return kit.Empty("未找到 EMS 日志召唤记录(" + kit.UpstreamText("FWH 平台 EMS 日志接口", err) + ")。"), nil
 		}
 		return kit.Object(rec), nil
 	},

+ 1 - 1
service/agentmcp/tools/misc.go

@@ -139,7 +139,7 @@ var PlaneIssueGet = kit.Def[planeIssueGetArgs]{
 						out["comments"] = comments
 					}
 				} else {
-					out["commentsError"] = err.Error()
+					out["commentsError"] = kit.UpstreamText("Plane 评论接口", err)
 				}
 			}
 		}

+ 2 - 0
service/agentmcp/tools/ops.go

@@ -73,6 +73,8 @@ var TouTemplateSave = kit.Def[touTemplateSaveArgs]{
 	Description: "在 FWH 测试服新增或更新 TOU 调度模版,只支持测试服。保存前校验每个策略的时段首尾衔接覆盖全天、sourceType 为 0;" +
 		"返回 templateId 与按调度模式识别后的策略摘要。这是写操作,执行前会请求确认。",
 	ReadOnly: false,
+	// 更新会覆盖测试服上已有的模版,不可恢复:声明为破坏性,自动模式下也每次审批
+	Destructive: true,
 	Handler: func(ctx context.Context, a touTemplateSaveArgs) (*mcp.CallToolResult, error) {
 		raw, err := kit.Required("payload", a.Payload)
 		if err != nil {

+ 3 - 3
service/agentmcp/tools/site.go

@@ -70,17 +70,17 @@ var SiteGet = kit.Def[siteGetArgs]{
 		var errs []string
 
 		if info, err := services.GetSiteInfoData(sn); err != nil {
-			errs = append(errs, "info: "+err.Error())
+			errs = append(errs, "info: "+kit.UpstreamText("站点信息表", err))
 		} else {
 			out["info"] = info
 		}
 		if mode, err := repositories.QuerySiteModeInfo(sn); err != nil {
-			errs = append(errs, "mode: "+err.Error())
+			errs = append(errs, "mode: "+kit.UpstreamText("FWH 平台工作模式接口", err))
 		} else if mode != nil {
 			out["mode"] = mode.Result
 		}
 		if alarms, err := repositories.QuerySiteAlarmList(sn); err != nil {
-			errs = append(errs, "currentAlarms: "+err.Error())
+			errs = append(errs, "currentAlarms: "+kit.UpstreamText("FWH 平台实时告警接口", err))
 		} else if alarms != nil && alarms.Result != nil {
 			list := *alarms.Result
 			out["currentAlarmTotal"] = len(list)

+ 2 - 0
service/agentmcp/tools/siteinfosync.go

@@ -190,6 +190,8 @@ var SiteInfoRefresh = kit.Def[siteInfoRefreshArgs]{
 		"已触发时 action=triggered。返回中的 status 与 site_info_sync_status 相同,之后用 site_info_sync_status 跟踪进度。" +
 		"建议先调用 site_info_sync_status 判断是否需要同步,再调用本工具。这是写操作,执行前会请求确认。",
 	ReadOnly: false,
+	// 同步中或数据较新时不重复触发,网络出错时平台重试一次不会产生第二次同步
+	Idempotent: true,
 	Handler: func(ctx context.Context, a siteInfoRefreshArgs) (*mcp.CallToolResult, error) {
 		s, err := loadSiteInfoSync()
 		if err != nil {