| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372 |
- 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)
- }
|