task.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. package kit
  2. // 持久化长任务(MCP Tasks,规范 2025-11-25;AgentServer 接入指南 §5)。
  3. //
  4. // mcp-go 的任务只保存在进程内存里,fwh-agent-mcp 重启后平台查询会得到“任务不存在”而按失败通知用户,
  5. // 而底层作业(如主服务的站点信息同步)其实还在执行。所以长任务工具不使用 mcp-go 的任务表:
  6. // - 任务状态由业务数据给出(如 Mongo 中的同步记录),MCP 进程不保存任何任务状态,重启后照常回答查询;
  7. // - 任务 id 携带工具名、业务键与标记,并带 HMAC 签名,不可伪造、不可猜测;
  8. // - TaskRouter 在 mcp-go 之前处理“以任务方式调用工具”与 tasks/get、tasks/result、tasks/cancel,其余请求原样交给 mcp-go。
  9. // 普通调用(不带 task 参数)照常由 mcp-go 按 Def.Handler 同步返回。
  10. import (
  11. "bytes"
  12. "context"
  13. "crypto/hmac"
  14. "crypto/sha256"
  15. "encoding/hex"
  16. "encoding/json"
  17. "errors"
  18. "fmt"
  19. "io"
  20. "net/http"
  21. "strings"
  22. "time"
  23. "github.com/mark3labs/mcp-go/mcp"
  24. "github.com/mark3labs/mcp-go/server"
  25. "go.uber.org/zap"
  26. )
  27. const (
  28. taskIDPrefix = "fwhtask"
  29. immediateMeta = "io.modelcontextprotocol/model-immediate-response"
  30. relatedMeta = "io.modelcontextprotocol/related-task"
  31. // defaultTaskTTL 调用方未指定 ttl 时声明的保留时间(毫秒)。业务数据本身长期保存,这里只是告知调用方
  32. defaultTaskTTL = int64(24 * time.Hour / time.Millisecond)
  33. // resultWaitStep tasks/result 在任务未结束时的查询间隔(规范要求阻塞到任务结束)
  34. resultWaitStep = 5 * time.Second
  35. maxRPCBody = 1 << 20
  36. )
  37. // ErrTaskNotFound 业务键对应的数据不存在(返回给调用方“找不到任务”)
  38. var ErrTaskNotFound = errors.New("task not found")
  39. // TaskRef 任务 id 中携带的信息
  40. type TaskRef struct {
  41. Tool string // 工具名
  42. Key string // 业务键,如同步记录 id(只含字母数字)
  43. Flag string // 工具自定义的标记,如本次调用是否触发了作业(只含小写字母、数字、下划线)
  44. }
  45. // TaskStart 以任务方式调用时 Start 的返回
  46. type TaskStart struct {
  47. Key string
  48. Flag string
  49. Note string // 立即交给模型的说明(model-immediate-response),如“已开始同步,预计约 1 小时”
  50. }
  51. // TaskState 由业务数据计算出的任务状态
  52. type TaskState struct {
  53. Status mcp.TaskStatus
  54. Message string // statusMessage,显示在平台的后台调用栏
  55. Result *mcp.CallToolResult // 终态时的结果;failed 时应为 isError
  56. CreatedAt time.Time
  57. }
  58. // TaskTool 持久化长任务工具
  59. type TaskTool interface {
  60. Registrar
  61. PollInterval() time.Duration
  62. // StartTask 立即返回(几秒内):启动或挂到已有作业上。参数或业务错误时返回 reject(作为错误返回给调用方)
  63. StartTask(ctx context.Context, args json.RawMessage) (start TaskStart, reject *mcp.CallToolResult)
  64. // TaskStatus 按业务数据计算任务状态;数据不存在时返回 ErrTaskNotFound
  65. TaskStatus(ctx context.Context, ref TaskRef) (TaskState, error)
  66. // CancelTask 取消任务;由工具按 ref.Flag 决定是否真的停止作业(只是跟踪他人触发的作业时不停止)
  67. CancelTask(ctx context.Context, ref TaskRef) error
  68. }
  69. // TaskDef 类型化的持久化长任务工具:Def 用于普通调用,其余回调用于任务方式调用
  70. type TaskDef[T any] struct {
  71. Def[T]
  72. Poll time.Duration
  73. Start func(ctx context.Context, args T) (TaskStart, *mcp.CallToolResult)
  74. Status func(ctx context.Context, ref TaskRef) (TaskState, error)
  75. Cancel func(ctx context.Context, ref TaskRef) error
  76. }
  77. func (d TaskDef[T]) Tool() mcp.Tool {
  78. t := d.Def.Tool()
  79. mcp.WithTaskSupport(mcp.TaskSupportOptional)(&t)
  80. return t
  81. }
  82. func (d TaskDef[T]) Register(s *server.MCPServer) {
  83. s.AddTool(d.Tool(), mcp.NewTypedToolHandler(func(ctx context.Context, _ mcp.CallToolRequest, args T) (*mcp.CallToolResult, error) {
  84. return d.Handler(ctx, args)
  85. }))
  86. }
  87. func (d TaskDef[T]) PollInterval() time.Duration { return d.Poll }
  88. func (d TaskDef[T]) StartTask(ctx context.Context, raw json.RawMessage) (TaskStart, *mcp.CallToolResult) {
  89. var args T
  90. if len(raw) > 0 && string(raw) != "null" {
  91. if err := json.Unmarshal(raw, &args); err != nil {
  92. return TaskStart{}, Failf("参数格式不正确:%v", err)
  93. }
  94. }
  95. return d.Start(ctx, args)
  96. }
  97. func (d TaskDef[T]) TaskStatus(ctx context.Context, ref TaskRef) (TaskState, error) {
  98. return d.Status(ctx, ref)
  99. }
  100. func (d TaskDef[T]) CancelTask(ctx context.Context, ref TaskRef) error { return d.Cancel(ctx, ref) }
  101. // ---- 任务 id ----
  102. type taskIDCodec struct{ secret []byte }
  103. func (c taskIDCodec) encode(ref TaskRef) string {
  104. body := strings.Join([]string{taskIDPrefix, ref.Tool, ref.Key, ref.Flag, errorRef()}, ".")
  105. return body + "." + c.sign(body)
  106. }
  107. // decode 返回 ok=false 表示不是本服务签发的任务 id(交给 mcp-go);err 表示前缀匹配但校验失败
  108. func (c taskIDCodec) decode(id string) (ref TaskRef, ok bool, err error) {
  109. parts := strings.Split(id, ".")
  110. if len(parts) == 0 || parts[0] != taskIDPrefix {
  111. return TaskRef{}, false, nil
  112. }
  113. if len(parts) != 6 || !hmac.Equal([]byte(c.sign(strings.Join(parts[:5], "."))), []byte(parts[5])) {
  114. return TaskRef{}, true, ErrTaskNotFound
  115. }
  116. return TaskRef{Tool: parts[1], Key: parts[2], Flag: parts[3]}, true, nil
  117. }
  118. func (c taskIDCodec) sign(body string) string {
  119. m := hmac.New(sha256.New, c.secret)
  120. m.Write([]byte(body))
  121. return hex.EncodeToString(m.Sum(nil))[:32]
  122. }
  123. // ---- 协议处理 ----
  124. // TaskRouter 在 mcp-go 之前处理持久化长任务的协议请求
  125. type TaskRouter struct {
  126. next http.Handler
  127. tools map[string]TaskTool
  128. ids taskIDCodec
  129. log *zap.Logger
  130. }
  131. // NewTaskRouter secret 用于任务 id 签名(使用 MCP 访问令牌即可,只有持有令牌的调用方能访问本服务)
  132. func NewTaskRouter(next http.Handler, tools []TaskTool, secret string, log *zap.Logger) *TaskRouter {
  133. m := make(map[string]TaskTool, len(tools))
  134. for _, t := range tools {
  135. m[t.Meta().Name] = t
  136. }
  137. return &TaskRouter{next: next, tools: m, ids: taskIDCodec{secret: []byte(secret)}, log: log}
  138. }
  139. type rpcRequest struct {
  140. JSONRPC string `json:"jsonrpc"`
  141. ID json.RawMessage `json:"id"`
  142. Method string `json:"method"`
  143. Params json.RawMessage `json:"params"`
  144. }
  145. func (r *TaskRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
  146. if req.Method != http.MethodPost || len(r.tools) == 0 {
  147. r.next.ServeHTTP(w, req)
  148. return
  149. }
  150. body, err := io.ReadAll(io.LimitReader(req.Body, maxRPCBody))
  151. if err != nil {
  152. http.Error(w, "read body failed", http.StatusBadRequest)
  153. return
  154. }
  155. req.Body = io.NopCloser(bytes.NewReader(body))
  156. var rpc rpcRequest
  157. if json.Unmarshal(body, &rpc) != nil || len(rpc.ID) == 0 || !r.handle(w, req.Context(), rpc) {
  158. r.next.ServeHTTP(w, req)
  159. }
  160. }
  161. // handle 处理本服务负责的请求,返回 false 表示交给 mcp-go
  162. func (r *TaskRouter) handle(w http.ResponseWriter, ctx context.Context, rpc rpcRequest) bool {
  163. switch rpc.Method {
  164. case "tools/call":
  165. var p struct {
  166. Name string `json:"name"`
  167. Arguments json.RawMessage `json:"arguments"`
  168. Task *mcp.TaskParams `json:"task"`
  169. }
  170. if json.Unmarshal(rpc.Params, &p) != nil || p.Task == nil {
  171. return false
  172. }
  173. tool, ok := r.tools[p.Name]
  174. if !ok {
  175. return false
  176. }
  177. r.create(w, ctx, rpc.ID, tool, p.Arguments, p.Task.TTL)
  178. return true
  179. case "tasks/get", "tasks/result", "tasks/cancel":
  180. var p struct {
  181. TaskID string `json:"taskId"`
  182. }
  183. if json.Unmarshal(rpc.Params, &p) != nil {
  184. return false
  185. }
  186. ref, ours, err := r.ids.decode(p.TaskID)
  187. if !ours {
  188. return false
  189. }
  190. tool, known := r.tools[ref.Tool]
  191. if err != nil || !known {
  192. writeRPCError(w, rpc.ID, mcp.INVALID_PARAMS, "task not found")
  193. return true
  194. }
  195. switch rpc.Method {
  196. case "tasks/get":
  197. r.get(w, ctx, rpc.ID, tool, ref, p.TaskID)
  198. case "tasks/result":
  199. r.result(w, ctx, rpc.ID, tool, ref, p.TaskID)
  200. default:
  201. r.cancel(w, ctx, rpc.ID, tool, ref, p.TaskID)
  202. }
  203. return true
  204. }
  205. return false
  206. }
  207. func (r *TaskRouter) create(w http.ResponseWriter, ctx context.Context, id json.RawMessage, tool TaskTool, args json.RawMessage, ttl *int64) {
  208. start, reject := tool.StartTask(ctx, args)
  209. if reject != nil {
  210. writeRPCError(w, id, mcp.INVALID_PARAMS, resultText(reject))
  211. return
  212. }
  213. ref := TaskRef{Tool: tool.Meta().Name, Key: start.Key, Flag: start.Flag}
  214. taskID := r.ids.encode(ref)
  215. state, err := tool.TaskStatus(ctx, ref)
  216. if err != nil {
  217. r.log.Warn("[agent-mcp] 任务创建后查询状态失败", zap.String("taskId", taskID), zap.Error(err))
  218. writeRPCError(w, id, mcp.INTERNAL_ERROR, "任务已创建,但查询状态失败,请稍后重试")
  219. return
  220. }
  221. if ttl == nil {
  222. t := defaultTaskTTL
  223. ttl = &t
  224. }
  225. res := map[string]any{"task": r.task(taskID, tool, state, ttl)}
  226. if start.Note != "" {
  227. res["_meta"] = map[string]any{immediateMeta: start.Note}
  228. }
  229. 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)))
  230. writeRPCResult(w, id, res)
  231. }
  232. func (r *TaskRouter) get(w http.ResponseWriter, ctx context.Context, id json.RawMessage, tool TaskTool, ref TaskRef, taskID string) {
  233. state, ok := r.status(w, ctx, id, tool, ref)
  234. if ok {
  235. writeRPCResult(w, id, r.task(taskID, tool, state, nil))
  236. }
  237. }
  238. // result 阻塞到任务结束(规范要求),然后返回工具结果
  239. func (r *TaskRouter) result(w http.ResponseWriter, ctx context.Context, id json.RawMessage, tool TaskTool, ref TaskRef, taskID string) {
  240. for {
  241. state, ok := r.status(w, ctx, id, tool, ref)
  242. if !ok {
  243. return
  244. }
  245. if state.Status.IsTerminal() {
  246. res := state.Result
  247. if res == nil {
  248. res = mcp.NewToolResultText(state.Message)
  249. res.IsError = state.Status != mcp.TaskStatusCompleted
  250. }
  251. out := *res
  252. out.Meta = &mcp.Meta{AdditionalFields: map[string]any{relatedMeta: map[string]any{"taskId": taskID}}}
  253. writeRPCResult(w, id, out)
  254. return
  255. }
  256. select {
  257. case <-ctx.Done():
  258. writeRPCError(w, id, mcp.INTERNAL_ERROR, "等待任务结束时连接已断开")
  259. return
  260. case <-time.After(resultWaitStep):
  261. }
  262. }
  263. }
  264. func (r *TaskRouter) cancel(w http.ResponseWriter, ctx context.Context, id json.RawMessage, tool TaskTool, ref TaskRef, taskID string) {
  265. state, ok := r.status(w, ctx, id, tool, ref)
  266. if !ok {
  267. return
  268. }
  269. if state.Status.IsTerminal() {
  270. writeRPCError(w, id, mcp.INVALID_PARAMS, fmt.Sprintf("cannot cancel task in terminal status: %s", state.Status))
  271. return
  272. }
  273. if err := tool.CancelTask(ctx, ref); err != nil {
  274. r.log.Warn("[agent-mcp] 取消任务失败", zap.String("taskId", taskID), zap.Error(err))
  275. writeRPCError(w, id, mcp.INTERNAL_ERROR, "取消任务失败,请稍后重试")
  276. return
  277. }
  278. r.log.Info("[agent-mcp] 任务已取消", zap.String("taskId", taskID), zap.String("flag", ref.Flag))
  279. state.Status, state.Message = mcp.TaskStatusCancelled, "已取消"
  280. writeRPCResult(w, id, r.task(taskID, tool, state, nil))
  281. }
  282. func (r *TaskRouter) status(w http.ResponseWriter, ctx context.Context, id json.RawMessage, tool TaskTool, ref TaskRef) (TaskState, bool) {
  283. state, err := tool.TaskStatus(ctx, ref)
  284. switch {
  285. case errors.Is(err, ErrTaskNotFound):
  286. writeRPCError(w, id, mcp.INVALID_PARAMS, "task not found")
  287. return state, false
  288. case err != nil:
  289. // 暂时性错误:平台下次再查(连续失败 30 分钟才按失败处理)
  290. r.log.Warn("[agent-mcp] 查询任务状态失败", zap.String("tool", ref.Tool), zap.String("key", ref.Key), zap.Error(err))
  291. writeRPCError(w, id, mcp.INTERNAL_ERROR, "查询任务状态失败,请稍后重试")
  292. return state, false
  293. }
  294. return state, true
  295. }
  296. func (r *TaskRouter) task(taskID string, tool TaskTool, s TaskState, ttl *int64) mcp.Task {
  297. if ttl == nil {
  298. t := defaultTaskTTL
  299. ttl = &t
  300. }
  301. poll := int64(tool.PollInterval() / time.Millisecond)
  302. created := s.CreatedAt
  303. if created.IsZero() {
  304. created = time.Now()
  305. }
  306. return mcp.Task{
  307. TaskId: taskID,
  308. Status: s.Status,
  309. StatusMessage: s.Message,
  310. CreatedAt: created.UTC().Format(time.RFC3339),
  311. LastUpdatedAt: time.Now().UTC().Format(time.RFC3339),
  312. TTL: ttl,
  313. PollInterval: &poll,
  314. }
  315. }
  316. func resultText(r *mcp.CallToolResult) string {
  317. var parts []string
  318. for _, c := range r.Content {
  319. if t, ok := c.(mcp.TextContent); ok {
  320. parts = append(parts, t.Text)
  321. }
  322. }
  323. return strings.Join(parts, "\n")
  324. }
  325. func writeRPCResult(w http.ResponseWriter, id json.RawMessage, result any) {
  326. writeRPC(w, map[string]any{"jsonrpc": "2.0", "id": id, "result": result})
  327. }
  328. func writeRPCError(w http.ResponseWriter, id json.RawMessage, code int, msg string) {
  329. writeRPC(w, map[string]any{"jsonrpc": "2.0", "id": id, "error": map[string]any{"code": code, "message": msg}})
  330. }
  331. func writeRPC(w http.ResponseWriter, v any) {
  332. w.Header().Set("Content-Type", "application/json")
  333. _ = json.NewEncoder(w).Encode(v)
  334. }