| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165 |
- // Package middleware 提供 MCP 工具调用的横切关注点(日志等)。
- package middleware
- import (
- "context"
- "encoding/json"
- "fmt"
- "runtime/debug"
- "strings"
- "time"
- "github.com/mark3labs/mcp-go/mcp"
- "github.com/mark3labs/mcp-go/server"
- "go.uber.org/zap"
- "fwh-toolbox-server/cmd/mcp/tools/helper"
- )
- // maxParamsLogBytes 参数写入日志时的最大字节数,超过则截断,避免日志过大。
- const maxParamsLogBytes = 2 * 1024
- // maxErrLogBytes 错误结果内容写入日志时的最大字节数。
- const maxErrLogBytes = 1 * 1024
- // requestSeq 为每次调用生成一个进程内自增的请求序号,用于关联同一次调用的多条日志。
- var requestSeq int64
- // LoggingMiddleware 返回一个 MCP 工具处理中间件,统一记录工具调用日志:
- //
- // 第 1 类(调用入口/出口):
- // - 入口:tool、request_id、脱敏后的 params(截断)
- // - 出口:duration_ms、status、result_size / spilled
- //
- // 第 2 类(错误与异常):
- // - handler 返回 Go error -> level=error
- // - 业务错误结果(result.IsError)-> level=warn,附错误内容摘要
- // - panic -> level=error,附堆栈,并重新抛出(保持 WithRecovery 行为)
- func LoggingMiddleware() server.ToolHandlerMiddleware {
- return func(next server.ToolHandlerFunc) server.ToolHandlerFunc {
- return func(ctx context.Context, req mcp.CallToolRequest) (result *mcp.CallToolResult, err error) {
- tool := req.Params.Name
- requestID := nextRequestID(tool)
- start := time.Now()
- // 入口日志
- zap.L().Info("[tool] call start",
- zap.String("request_id", requestID),
- zap.String("tool", tool),
- zap.String("params", maskedParams(req)),
- )
- // panic 兜底:记录后重新抛出,交由上层 WithRecovery 处理
- defer func() {
- if r := recover(); r != nil {
- zap.L().Error("[tool] call panic",
- zap.String("request_id", requestID),
- zap.String("tool", tool),
- zap.Int64("duration_ms", time.Since(start).Milliseconds()),
- zap.Any("panic", r),
- zap.String("stack", string(debug.Stack())),
- )
- panic(r)
- }
- }()
- result, err = next(ctx, req)
- durationMS := time.Since(start).Milliseconds()
- switch {
- case err != nil:
- // handler 返回 Go error(框架级/传输级错误)
- zap.L().Error("[tool] call error",
- zap.String("request_id", requestID),
- zap.String("tool", tool),
- zap.Int64("duration_ms", durationMS),
- zap.String("status", "error"),
- zap.Error(err),
- )
- case result != nil && result.IsError:
- // 业务错误结果(handler 返回 mcp.NewToolResultError)
- zap.L().Warn("[tool] call business_error",
- zap.String("request_id", requestID),
- zap.String("tool", tool),
- zap.Int64("duration_ms", durationMS),
- zap.String("status", "business_error"),
- zap.String("err", truncate(resultText(result), maxErrLogBytes)),
- )
- default:
- // 成功
- size, spilled := resultMeta(result)
- zap.L().Info("[tool] call success",
- zap.String("request_id", requestID),
- zap.String("tool", tool),
- zap.Int64("duration_ms", durationMS),
- zap.String("status", "success"),
- zap.Int("result_size", size),
- zap.Bool("spilled", spilled),
- )
- }
- return result, err
- }
- }
- }
- // nextRequestID 生成一个进程内唯一的请求 ID,格式:<tool>-<纳秒时间戳>-<序号>。
- func nextRequestID(tool string) string {
- requestSeq++
- return fmt.Sprintf("%s-%d-%d", tool, time.Now().UnixNano(), requestSeq)
- }
- // maskedParams 将入参序列化并做 PII 脱敏,超长则截断。脱敏失败时不泄露原始值。
- func maskedParams(req mcp.CallToolRequest) string {
- args := req.GetArguments()
- if len(args) == 0 {
- return "{}"
- }
- b, err := json.Marshal(args)
- if err != nil {
- return "<marshal error>"
- }
- masked, err := helper.MaskJSONBytes(b)
- if err != nil {
- // fail-closed:脱敏失败则不输出原始参数
- return "<masked: sanitize failed>"
- }
- return truncate(string(masked), maxParamsLogBytes)
- }
- // resultText 拼接结果中的文本内容。
- func resultText(result *mcp.CallToolResult) string {
- if result == nil {
- return ""
- }
- var sb strings.Builder
- for _, c := range result.Content {
- if tc, ok := c.(mcp.TextContent); ok {
- sb.WriteString(tc.Text)
- }
- }
- return sb.String()
- }
- // resultMeta 返回结果文本字节数,以及是否为落盘结果(spilled)。
- func resultMeta(result *mcp.CallToolResult) (size int, spilled bool) {
- text := resultText(result)
- size = len(text)
- // 落盘结果的返回体是包含 "localPath" 的 JSON 摘要(数据类返回已统一为无条件落盘)。
- if strings.Contains(text, `"localPath"`) {
- spilled = true
- }
- return size, spilled
- }
- // truncate 按字节截断日志字段,避免单条日志过大。
- func truncate(s string, max int) string {
- if len(s) <= max {
- return s
- }
- end := max
- for end > 0 && s[end]&0xC0 == 0x80 { // 回退到 UTF-8 rune 边界
- end--
- }
- return s[:end] + "...(truncated)"
- }
|