package kit // 持久化长任务(MCP Tasks,规范 2025-11-25;AgentServer 接入指南 §5)。 // // mcp-go 的任务只保存在进程内存里,fwh-agent-mcp 重启后平台查询会得到“任务不存在”而按失败通知用户, // 而底层作业(如主服务的站点信息同步)其实还在执行。所以长任务工具不使用 mcp-go 的任务表: // - 任务状态由业务数据给出(如 Mongo 中的同步记录),MCP 进程不保存任何任务状态,重启后照常回答查询; // - 任务 id 携带工具名、业务键与标记,并带 HMAC 签名,不可伪造、不可猜测; // - TaskRouter 在 mcp-go 之前处理“以任务方式调用工具”与 tasks/get、tasks/result、tasks/cancel,其余请求原样交给 mcp-go。 // 普通调用(不带 task 参数)照常由 mcp-go 按 Def.Handler 同步返回。 import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "time" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "go.uber.org/zap" ) const ( taskIDPrefix = "fwhtask" immediateMeta = "io.modelcontextprotocol/model-immediate-response" relatedMeta = "io.modelcontextprotocol/related-task" // defaultTaskTTL 调用方未指定 ttl 时声明的保留时间(毫秒)。业务数据本身长期保存,这里只是告知调用方 defaultTaskTTL = int64(24 * time.Hour / time.Millisecond) // resultWaitStep tasks/result 在任务未结束时的查询间隔(规范要求阻塞到任务结束) resultWaitStep = 5 * time.Second maxRPCBody = 1 << 20 ) // ErrTaskNotFound 业务键对应的数据不存在(返回给调用方“找不到任务”) var ErrTaskNotFound = errors.New("task not found") // TaskRef 任务 id 中携带的信息 type TaskRef struct { Tool string // 工具名 Key string // 业务键,如同步记录 id(只含字母数字) Flag string // 工具自定义的标记,如本次调用是否触发了作业(只含小写字母、数字、下划线) } // TaskStart 以任务方式调用时 Start 的返回 type TaskStart struct { Key string Flag string Note string // 立即交给模型的说明(model-immediate-response),如“已开始同步,预计约 1 小时” } // TaskState 由业务数据计算出的任务状态 type TaskState struct { Status mcp.TaskStatus Message string // statusMessage,显示在平台的后台调用栏 Result *mcp.CallToolResult // 终态时的结果;failed 时应为 isError CreatedAt time.Time } // TaskTool 持久化长任务工具 type TaskTool interface { Registrar PollInterval() time.Duration // StartTask 立即返回(几秒内):启动或挂到已有作业上。参数或业务错误时返回 reject(作为错误返回给调用方) StartTask(ctx context.Context, args json.RawMessage) (start TaskStart, reject *mcp.CallToolResult) // TaskStatus 按业务数据计算任务状态;数据不存在时返回 ErrTaskNotFound TaskStatus(ctx context.Context, ref TaskRef) (TaskState, error) // CancelTask 取消任务;由工具按 ref.Flag 决定是否真的停止作业(只是跟踪他人触发的作业时不停止) CancelTask(ctx context.Context, ref TaskRef) error } // TaskDef 类型化的持久化长任务工具:Def 用于普通调用,其余回调用于任务方式调用 type TaskDef[T any] struct { Def[T] Poll time.Duration Start func(ctx context.Context, args T) (TaskStart, *mcp.CallToolResult) Status func(ctx context.Context, ref TaskRef) (TaskState, error) Cancel func(ctx context.Context, ref TaskRef) error } func (d TaskDef[T]) Tool() mcp.Tool { t := d.Def.Tool() mcp.WithTaskSupport(mcp.TaskSupportOptional)(&t) return t } func (d TaskDef[T]) Register(s *server.MCPServer) { s.AddTool(d.Tool(), mcp.NewTypedToolHandler(func(ctx context.Context, _ mcp.CallToolRequest, args T) (*mcp.CallToolResult, error) { return d.Handler(ctx, args) })) } func (d TaskDef[T]) PollInterval() time.Duration { return d.Poll } func (d TaskDef[T]) StartTask(ctx context.Context, raw json.RawMessage) (TaskStart, *mcp.CallToolResult) { var args T if len(raw) > 0 && string(raw) != "null" { if err := json.Unmarshal(raw, &args); err != nil { return TaskStart{}, Failf("参数格式不正确:%v", err) } } return d.Start(ctx, args) } func (d TaskDef[T]) TaskStatus(ctx context.Context, ref TaskRef) (TaskState, error) { return d.Status(ctx, ref) } func (d TaskDef[T]) CancelTask(ctx context.Context, ref TaskRef) error { return d.Cancel(ctx, ref) } // ---- 任务 id ---- type taskIDCodec struct{ secret []byte } func (c taskIDCodec) encode(ref TaskRef) string { body := strings.Join([]string{taskIDPrefix, ref.Tool, ref.Key, ref.Flag, errorRef()}, ".") return body + "." + c.sign(body) } // decode 返回 ok=false 表示不是本服务签发的任务 id(交给 mcp-go);err 表示前缀匹配但校验失败 func (c taskIDCodec) decode(id string) (ref TaskRef, ok bool, err error) { parts := strings.Split(id, ".") if len(parts) == 0 || parts[0] != taskIDPrefix { return TaskRef{}, false, nil } if len(parts) != 6 || !hmac.Equal([]byte(c.sign(strings.Join(parts[:5], "."))), []byte(parts[5])) { return TaskRef{}, true, ErrTaskNotFound } return TaskRef{Tool: parts[1], Key: parts[2], Flag: parts[3]}, true, nil } func (c taskIDCodec) sign(body string) string { m := hmac.New(sha256.New, c.secret) m.Write([]byte(body)) return hex.EncodeToString(m.Sum(nil))[:32] } // ---- 协议处理 ---- // TaskRouter 在 mcp-go 之前处理持久化长任务的协议请求 type TaskRouter struct { next http.Handler tools map[string]TaskTool ids taskIDCodec log *zap.Logger } // NewTaskRouter secret 用于任务 id 签名(使用 MCP 访问令牌即可,只有持有令牌的调用方能访问本服务) func NewTaskRouter(next http.Handler, tools []TaskTool, secret string, log *zap.Logger) *TaskRouter { m := make(map[string]TaskTool, len(tools)) for _, t := range tools { m[t.Meta().Name] = t } return &TaskRouter{next: next, tools: m, ids: taskIDCodec{secret: []byte(secret)}, log: log} } type rpcRequest struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id"` Method string `json:"method"` Params json.RawMessage `json:"params"` } func (r *TaskRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { if req.Method != http.MethodPost || len(r.tools) == 0 { r.next.ServeHTTP(w, req) return } body, err := io.ReadAll(io.LimitReader(req.Body, maxRPCBody)) if err != nil { http.Error(w, "read body failed", http.StatusBadRequest) return } req.Body = io.NopCloser(bytes.NewReader(body)) var rpc rpcRequest if json.Unmarshal(body, &rpc) != nil || len(rpc.ID) == 0 || !r.handle(w, req.Context(), rpc) { r.next.ServeHTTP(w, req) } } // handle 处理本服务负责的请求,返回 false 表示交给 mcp-go func (r *TaskRouter) handle(w http.ResponseWriter, ctx context.Context, rpc rpcRequest) bool { switch rpc.Method { case "tools/call": var p struct { Name string `json:"name"` Arguments json.RawMessage `json:"arguments"` Task *mcp.TaskParams `json:"task"` } if json.Unmarshal(rpc.Params, &p) != nil || p.Task == nil { return false } tool, ok := r.tools[p.Name] if !ok { return false } r.create(w, ctx, rpc.ID, tool, p.Arguments, p.Task.TTL) return true case "tasks/get", "tasks/result", "tasks/cancel": var p struct { TaskID string `json:"taskId"` } if json.Unmarshal(rpc.Params, &p) != nil { return false } ref, ours, err := r.ids.decode(p.TaskID) if !ours { return false } tool, known := r.tools[ref.Tool] if err != nil || !known { writeRPCError(w, rpc.ID, mcp.INVALID_PARAMS, "task not found") return true } switch rpc.Method { case "tasks/get": r.get(w, ctx, rpc.ID, tool, ref, p.TaskID) case "tasks/result": r.result(w, ctx, rpc.ID, tool, ref, p.TaskID) default: r.cancel(w, ctx, rpc.ID, tool, ref, p.TaskID) } return true } return false } func (r *TaskRouter) create(w http.ResponseWriter, ctx context.Context, id json.RawMessage, tool TaskTool, args json.RawMessage, ttl *int64) { start, reject := tool.StartTask(ctx, args) if reject != nil { writeRPCError(w, id, mcp.INVALID_PARAMS, resultText(reject)) return } ref := TaskRef{Tool: tool.Meta().Name, Key: start.Key, Flag: start.Flag} taskID := r.ids.encode(ref) state, err := tool.TaskStatus(ctx, ref) if err != nil { r.log.Warn("[agent-mcp] 任务创建后查询状态失败", zap.String("taskId", taskID), zap.Error(err)) writeRPCError(w, id, mcp.INTERNAL_ERROR, "任务已创建,但查询状态失败,请稍后重试") return } if ttl == nil { t := defaultTaskTTL ttl = &t } res := map[string]any{"task": r.task(taskID, tool, state, ttl)} if start.Note != "" { res["_meta"] = map[string]any{immediateMeta: start.Note} } r.log.Info("[agent-mcp] 任务已创建", zap.String("tool", ref.Tool), zap.String("taskId", taskID), zap.String("flag", ref.Flag), zap.String("status", string(state.Status))) writeRPCResult(w, id, res) } func (r *TaskRouter) get(w http.ResponseWriter, ctx context.Context, id json.RawMessage, tool TaskTool, ref TaskRef, taskID string) { state, ok := r.status(w, ctx, id, tool, ref) if ok { writeRPCResult(w, id, r.task(taskID, tool, state, nil)) } } // result 阻塞到任务结束(规范要求),然后返回工具结果 func (r *TaskRouter) result(w http.ResponseWriter, ctx context.Context, id json.RawMessage, tool TaskTool, ref TaskRef, taskID string) { for { state, ok := r.status(w, ctx, id, tool, ref) if !ok { return } if state.Status.IsTerminal() { res := state.Result if res == nil { res = mcp.NewToolResultText(state.Message) res.IsError = state.Status != mcp.TaskStatusCompleted } out := *res out.Meta = &mcp.Meta{AdditionalFields: map[string]any{relatedMeta: map[string]any{"taskId": taskID}}} writeRPCResult(w, id, out) return } select { case <-ctx.Done(): writeRPCError(w, id, mcp.INTERNAL_ERROR, "等待任务结束时连接已断开") return case <-time.After(resultWaitStep): } } } func (r *TaskRouter) cancel(w http.ResponseWriter, ctx context.Context, id json.RawMessage, tool TaskTool, ref TaskRef, taskID string) { state, ok := r.status(w, ctx, id, tool, ref) if !ok { return } if state.Status.IsTerminal() { writeRPCError(w, id, mcp.INVALID_PARAMS, fmt.Sprintf("cannot cancel task in terminal status: %s", state.Status)) return } if err := tool.CancelTask(ctx, ref); err != nil { r.log.Warn("[agent-mcp] 取消任务失败", zap.String("taskId", taskID), zap.Error(err)) writeRPCError(w, id, mcp.INTERNAL_ERROR, "取消任务失败,请稍后重试") return } r.log.Info("[agent-mcp] 任务已取消", zap.String("taskId", taskID), zap.String("flag", ref.Flag)) state.Status, state.Message = mcp.TaskStatusCancelled, "已取消" writeRPCResult(w, id, r.task(taskID, tool, state, nil)) } func (r *TaskRouter) status(w http.ResponseWriter, ctx context.Context, id json.RawMessage, tool TaskTool, ref TaskRef) (TaskState, bool) { state, err := tool.TaskStatus(ctx, ref) switch { case errors.Is(err, ErrTaskNotFound): writeRPCError(w, id, mcp.INVALID_PARAMS, "task not found") return state, false case err != nil: // 暂时性错误:平台下次再查(连续失败 30 分钟才按失败处理) r.log.Warn("[agent-mcp] 查询任务状态失败", zap.String("tool", ref.Tool), zap.String("key", ref.Key), zap.Error(err)) writeRPCError(w, id, mcp.INTERNAL_ERROR, "查询任务状态失败,请稍后重试") return state, false } return state, true } func (r *TaskRouter) task(taskID string, tool TaskTool, s TaskState, ttl *int64) mcp.Task { if ttl == nil { t := defaultTaskTTL ttl = &t } poll := int64(tool.PollInterval() / time.Millisecond) created := s.CreatedAt if created.IsZero() { created = time.Now() } return mcp.Task{ TaskId: taskID, Status: s.Status, StatusMessage: s.Message, CreatedAt: created.UTC().Format(time.RFC3339), LastUpdatedAt: time.Now().UTC().Format(time.RFC3339), TTL: ttl, PollInterval: &poll, } } func resultText(r *mcp.CallToolResult) string { var parts []string for _, c := range r.Content { if t, ok := c.(mcp.TextContent); ok { parts = append(parts, t.Text) } } return strings.Join(parts, "\n") } func writeRPCResult(w http.ResponseWriter, id json.RawMessage, result any) { writeRPC(w, map[string]any{"jsonrpc": "2.0", "id": id, "result": result}) } func writeRPCError(w http.ResponseWriter, id json.RawMessage, code int, msg string) { writeRPC(w, map[string]any{"jsonrpc": "2.0", "id": id, "error": map[string]any{"code": code, "message": msg}}) } func writeRPC(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(v) }