Selaa lähdekoodia

feat:fwh-agent-mcp支持持久化长任务,site_info_refresh改为MCP Task执行

- kit.TaskDef / kit.TaskRouter:在 mcp-go 之前处理以任务方式调用工具与 tasks/get、tasks/result、tasks/cancel;
  任务状态由业务数据给出,MCP 进程不保存任务状态,重启后照常回答查询;任务 id 以访问令牌 HMAC 签名
- site_info_refresh:任务跟随同步记录(Mongo),同步结束后平台把结果发给模型;
  只取消本次触发的同步,跟踪他人触发的同步时取消只停止跟踪
- 技能流程改为调用后结束本轮、等平台发来结果;设计文档新增长任务一节

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 4 päivää sitten
vanhempi
commit
e8c0aef11c

+ 7 - 9
deploy/agent-assistant/skills/fwh-assistant/references/write-operations.md

@@ -12,15 +12,13 @@
 
 ## 站点信息同步
 
-同步在主服务后台执行,耗时可能超过 1.5 小时;`site_info_refresh` 立即返回,不等同步完成。
-
-1. **先查状态**:调用只读工具 `mcp__gateway__fwh__site_info_sync_status`(无需审批),按 `state` 处理:
-   - `syncing`:同步进行中,**不要触发**,告诉用户已进行多久,之后再用本工具查看;
-   - `stale`:超过 6 小时未完成,疑似中断(如主服务重启)。向用户说明情况,用户确认后才用 `force=true` 重新触发;
-   - `idle` 且最近一次成功同步在 24 小时内(`advice` 会说明):数据较新,告诉用户数据截至 `lastSuccess.completedAt`,一般无需同步;用户坚持要最新数据时用 `force=true`;
-   - `idle` 且超过 24 小时、上次失败,或 `never`:可以触发。
-2. **再触发**:调用 `site_info_refresh`。工具会再次检查状态,按返回的 `action` 回复:`triggered`(已触发)、`tracking`(同步中,未重复触发)、`skipped_fresh` / `skipped_stale`(未触发,原因见 `message`)。
-3. **跟踪**:触发后告诉用户同步需要较长时间、完成后 `site_search` 才会更新;用户之后询问进度时用 `site_info_sync_status` 查看,不要在一轮对话中反复轮询。
+同步在主服务后台执行,约 1 小时,可能超过 1.5 小时。`site_info_refresh` 以长任务方式执行:工具会先检查同步状态再决定是否触发,不会重复触发。
+
+1. **用户要求同步**:直接调用 `site_info_refresh`(不带 `force`)。
+   - 触发了新同步,或已有同步在进行(改为跟踪它):工具立即返回“已转为后台执行”。告诉用户已提交、需要较长时间、完成后 `site_search` 才会更新,然后结束这一轮。**同步结束后平台会把结果作为新消息发给你**(完成时间、站点数、耗时,或失败原因),收到后向用户汇报。不要轮询。
+   - 未触发:最近 24 小时内已成功同步(`skipped_fresh`),或上次同步超过 6 小时未完成、疑似中断(`skipped_stale`)。把原因和数据截至的时间告诉用户;用户确认仍要同步时,再调用 `site_info_refresh` 并设置 `force=true`。
+2. **用户只问数据是否最新、同步进度如何**:用只读工具 `mcp__gateway__fwh__site_info_sync_status`(无需审批),按 `advice` 回答,不触发同步。
+3. **结果为失败**:说明失败原因,询问用户是否稍后重新同步;不要自动重试。
 
 通用规则:
 

+ 29 - 1
docs/ai-assistant-agentserver/agent-mcp.md

@@ -226,6 +226,34 @@ ADR-0021 的结果处理(`mcp-gateway/server.ts` 的 `postProcess`)目前:
 
 故障录波原始文件、日志包等二进制内容在一期不直接交给 Agent。需要时提供“解析后的结果”工具(如录波通道数据导出为 CSV),由 Agent MCP 在服务端解析;是否需要见 Q-03。上述 5.2 的改动落地后,也可以用 `blob` 资源交付原始文件。
 
+### 5.4 长任务(MCP Tasks)
+
+超过 3 分钟的操作以 MCP 长任务执行(AgentServer 接入指南 §5、`docs/design/16-mcp-support.md` §3):工具立即返回任务,AgentServer 在后台按 `pollInterval` 查询,结束后把结果作为新消息发到会话、唤醒模型。不做“提交 + 查询”让模型轮询;查询进度的只读工具保留,供用户主动询问。
+
+**任务状态不放在 MCP 进程里。** mcp-go 的任务只存在进程内存,fwh-agent-mcp 重启后平台查询得到“任务不存在”而按失败通知用户,而底层作业其实仍在执行。所以长任务工具用 `kit.TaskDef` 定义,由 `kit.TaskRouter` 在 mcp-go 之前处理任务协议:
+
+| 请求 | 处理 |
+| --- | --- |
+| `tools/call` 带 `task` | `Start`:启动或挂到已有作业上,立即返回任务(含 `pollInterval` 与给模型的即时说明 `model-immediate-response`) |
+| `tasks/get` | `Status`:读取业务数据(如 Mongo 中的作业记录)翻译为任务状态 |
+| `tasks/result` | 任务结束后返回工具结果(失败为 `isError`),带 `related-task` |
+| `tasks/cancel` | `Cancel`:由工具按任务 id 中的标记决定是否真的停止作业 |
+| 其他请求、不是本服务签发的任务 id | 原样交给 mcp-go |
+
+- 任务 id:`fwhtask.<工具>.<业务键>.<标记>.<随机数>.<签名>`,签名为以访问令牌为密钥的 HMAC,伪造或篡改的任务 id 一律返回“找不到任务”。
+- MCP 进程不保存任何任务状态,重启后照常回答查询;业务数据长期保存,满足平台至少 24 小时的要求。
+- 不带 `task` 的普通调用照常由 `Def.Handler` 同步返回。
+
+`site_info_refresh` 是第一个长任务工具:业务键为同步记录(`SiteInfoManageRecord`)的 id,标记为本次调用的动作。
+
+| 动作(标记) | 任务 |
+| --- | --- |
+| `triggered`(本次触发) | 跟随同步记录:pending / running → working(进度说明“同步中,已进行 N 分钟”);complete → completed(站点数、耗时、完成时间);error → failed;cancelled → cancelled;超过 6 小时未完成 → failed(疑似中断)。取消时停止同步 |
+| `tracking`(已有同步在进行) | 同上,但取消只停止跟踪,不停止同步(其他会话可能也在等它) |
+| `skipped_fresh` / `skipped_stale`(未触发) | 立即完成,结果为不触发的原因 |
+
+取消同步由主服务配合:同步循环每页检查 ctx;`fwhQueue.CancelSiteInfoUpdate` 对执行中的任务发出取消(worker 停止后记为 cancelled),对排队中的直接删除。同步任务超时为 4 小时(`SiteInfoUpdateTimeout`),超时同样会停止同步并记为失败。
+
 ## 6. 部署与接入
 
 ### 6.1 Agent MCP
@@ -259,7 +287,7 @@ Profile `fwh-assistant` 勾选 `fwh`。工具使用的业务说明(字段含
 | --- | --- | --- |
 | `feedback_create` | 在 Plane 创建 Intake Issue | 提交人固定为服务账号“AI 助手”;描述中由 Agent 写明背景。需要真实提交人时由 ToolBox 页面提交,不经过 MCP |
 | `tou_template_save` | 保存测试服 TOU 模版 | 只允许测试服;写入前按调度配置需求中的规则校验,返回差异 |
-| `site_info_refresh` | 触发站点信息表同步,立即返回 | 触发前检查同步状态(与 `site_info_sync_status` 相同的判断):同步中不重复触发;最近 24 小时内已成功、或超过 6 小时未完成疑似中断时不触发,`force=true` 可强制。任务投递到主服务的队列执行(MCP 只创建入队客户端,Redis 库号与主服务一致) |
+| `site_info_refresh` | 触发站点信息表同步,以长任务执行(5.4) | 触发前检查同步状态(与 `site_info_sync_status` 相同的判断):同步中改为跟踪,不重复触发;最近 24 小时内已成功、或超过 6 小时未完成疑似中断时不触发,`force=true` 可强制。同步结束后平台把结果发给模型。任务投递到主服务的队列执行(MCP 只创建入队客户端与 Inspector,Redis 库号与主服务一致) |
 
 无用户模型下,写工具的“谁做的”只记录在 ToolBox 会话归属和 AgentServer 审计中,业务系统里看到的是服务账号。如果某个写操作在业务上必须归属到具体的人,就不放进 MCP。
 

+ 5 - 2
service/agentmcp/http.go

@@ -10,16 +10,19 @@ import (
 
 	"github.com/mark3labs/mcp-go/server"
 	"go.uber.org/zap"
+
+	"fwh-toolbox-server/agentmcp/kit"
 )
 
 // Handler 返回 HTTP 处理器:/mcp(Streamable HTTP,无状态,需 Bearer 令牌)与 /healthz。
-func Handler(s *server.MCPServer, token string) http.Handler {
+// tasks 为持久化长任务工具:任务协议请求由 kit.TaskRouter 在 mcp-go 之前处理(任务 id 以访问令牌签名)。
+func Handler(s *server.MCPServer, token string, tasks []kit.TaskTool, log *zap.Logger) http.Handler {
 	mcpHandler := server.NewStreamableHTTPServer(s,
 		server.WithStateLess(true),
 		server.WithEndpointPath("/mcp"),
 	)
 	mux := http.NewServeMux()
-	mux.Handle("/mcp", bearerAuth(token, mcpHandler))
+	mux.Handle("/mcp", bearerAuth(token, kit.NewTaskRouter(mcpHandler, tasks, token, log)))
 	mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
 		w.WriteHeader(http.StatusOK)
 		_, _ = w.Write([]byte("ok"))

+ 372 - 0
service/agentmcp/kit/task.go

@@ -0,0 +1,372 @@
+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)
+}

+ 172 - 0
service/agentmcp/kit/task_test.go

@@ -0,0 +1,172 @@
+package kit
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/mark3labs/mcp-go/mcp"
+	"go.uber.org/zap"
+)
+
+type fakeTaskArgs struct {
+	Name string `json:"name"`
+}
+
+// 业务数据:key -> 状态;cancelled 记录被真正取消的 key
+type fakeJobs struct {
+	status    map[string]mcp.TaskStatus
+	cancelled []string
+}
+
+func newFakeTaskTool(jobs *fakeJobs) TaskDef[fakeTaskArgs] {
+	return TaskDef[fakeTaskArgs]{
+		Def: Def[fakeTaskArgs]{Name: "job_run", Title: "运行作业", Description: "测试", Handler: func(context.Context, fakeTaskArgs) (*mcp.CallToolResult, error) {
+			return mcp.NewToolResultText("sync"), nil
+		}},
+		Poll: time.Minute,
+		Start: func(_ context.Context, a fakeTaskArgs) (TaskStart, *mcp.CallToolResult) {
+			if a.Name == "" {
+				return TaskStart{}, Failf("参数 name 必填")
+			}
+			jobs.status[a.Name] = mcp.TaskStatusWorking
+			return TaskStart{Key: a.Name, Flag: "owner", Note: "已开始"}, nil
+		},
+		Status: func(_ context.Context, ref TaskRef) (TaskState, error) {
+			st, ok := jobs.status[ref.Key]
+			if !ok {
+				return TaskState{}, ErrTaskNotFound
+			}
+			s := TaskState{Status: st, Message: string(st)}
+			if st == mcp.TaskStatusCompleted {
+				s.Result = mcp.NewToolResultText("done " + ref.Key)
+			}
+			if st == mcp.TaskStatusFailed {
+				s.Result = Failf("failed %s", ref.Key)
+			}
+			return s, nil
+		},
+		Cancel: func(_ context.Context, ref TaskRef) error {
+			if ref.Flag == "owner" {
+				jobs.cancelled = append(jobs.cancelled, ref.Key)
+			}
+			return nil
+		},
+	}
+}
+
+type rpcReply struct {
+	Result json.RawMessage `json:"result"`
+	Error  *struct {
+		Code    int    `json:"code"`
+		Message string `json:"message"`
+	} `json:"error"`
+}
+
+func TestTaskRouter(t *testing.T) {
+	jobs := &fakeJobs{status: map[string]mcp.TaskStatus{}}
+	passed := 0
+	next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		passed++
+		_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{}}`))
+	})
+	router := NewTaskRouter(next, []TaskTool{newFakeTaskTool(jobs)}, "secret", zap.NewNop())
+
+	call := func(method string, params any) rpcReply {
+		t.Helper()
+		body, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 7, "method": method, "params": params})
+		rec := httptest.NewRecorder()
+		router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body)))
+		var r rpcReply
+		if err := json.Unmarshal(rec.Body.Bytes(), &r); err != nil {
+			t.Fatalf("%s: 响应不是 JSON-RPC:%s", method, rec.Body.String())
+		}
+		return r
+	}
+
+	// 以任务方式调用:立即返回任务与即时说明
+	r := call("tools/call", map[string]any{"name": "job_run", "arguments": map[string]any{"name": "a"}, "task": map[string]any{"ttl": 1000}})
+	var created struct {
+		Task mcp.Task       `json:"task"`
+		Meta map[string]any `json:"_meta"`
+	}
+	if r.Error != nil || json.Unmarshal(r.Result, &created) != nil {
+		t.Fatalf("创建任务失败:%+v", r)
+	}
+	id := created.Task.TaskId
+	if !strings.HasPrefix(id, taskIDPrefix+".job_run.a.owner.") || created.Task.Status != mcp.TaskStatusWorking ||
+		*created.Task.TTL != 1000 || *created.Task.PollInterval != 60000 || created.Meta[immediateMeta] != "已开始" {
+		t.Errorf("创建任务的返回不符合预期:%+v", created)
+	}
+
+	// 参数错误:返回 JSON-RPC 错误
+	if r := call("tools/call", map[string]any{"name": "job_run", "arguments": map[string]any{}, "task": map[string]any{}}); r.Error == nil || !strings.Contains(r.Error.Message, "name 必填") {
+		t.Errorf("参数错误应返回错误:%+v", r)
+	}
+
+	// 查询状态
+	var got mcp.Task
+	if r := call("tasks/get", map[string]any{"taskId": id}); r.Error != nil || json.Unmarshal(r.Result, &got) != nil || got.Status != mcp.TaskStatusWorking {
+		t.Errorf("tasks/get 应为 working:%+v", r)
+	}
+
+	// 篡改任务 id:找不到任务
+	tampered := strings.Replace(id, ".a.", ".b.", 1)
+	if r := call("tasks/get", map[string]any{"taskId": tampered}); r.Error == nil || r.Error.Code != mcp.INVALID_PARAMS {
+		t.Errorf("篡改的任务 id 应返回 INVALID_PARAMS:%+v", r)
+	}
+
+	// 完成后取结果:带 related-task
+	jobs.status["a"] = mcp.TaskStatusCompleted
+	var res struct {
+		Content []mcp.TextContent `json:"content"`
+		IsError bool              `json:"isError"`
+		Meta    map[string]any    `json:"_meta"`
+	}
+	if r := call("tasks/result", map[string]any{"taskId": id}); r.Error != nil || json.Unmarshal(r.Result, &res) != nil ||
+		res.Content[0].Text != "done a" || res.IsError || res.Meta[relatedMeta] == nil {
+		t.Errorf("tasks/result 不符合预期:%+v %+v", r, res)
+	}
+
+	// 已结束的任务不能取消
+	if r := call("tasks/cancel", map[string]any{"taskId": id}); r.Error == nil {
+		t.Errorf("已完成的任务取消应报错:%+v", r)
+	}
+
+	// 取消进行中的任务:调用业务取消
+	r = call("tools/call", map[string]any{"name": "job_run", "arguments": map[string]any{"name": "b"}, "task": map[string]any{}})
+	_ = json.Unmarshal(r.Result, &created)
+	if r := call("tasks/cancel", map[string]any{"taskId": created.Task.TaskId}); r.Error != nil || json.Unmarshal(r.Result, &got) != nil || got.Status != mcp.TaskStatusCancelled {
+		t.Errorf("取消应返回 cancelled:%+v", r)
+	}
+	if len(jobs.cancelled) != 1 || jobs.cancelled[0] != "b" {
+		t.Errorf("应取消作业 b,实际 %v", jobs.cancelled)
+	}
+
+	// 失败的任务:结果为 isError
+	jobs.status["b"] = mcp.TaskStatusFailed
+	if r := call("tasks/result", map[string]any{"taskId": created.Task.TaskId}); json.Unmarshal(r.Result, &res) != nil || !res.IsError {
+		t.Errorf("失败任务的结果应为 isError:%+v", r)
+	}
+
+	// 不归本服务处理的请求交给 mcp-go:普通调用、别的任务 id、其他方法
+	passed = 0
+	call("tools/call", map[string]any{"name": "job_run", "arguments": map[string]any{"name": "c"}})
+	call("tasks/get", map[string]any{"taskId": "other-task"})
+	call("tools/list", map[string]any{})
+	if passed != 3 {
+		t.Errorf("应有 3 个请求交给 mcp-go,实际 %d", passed)
+	}
+}
+
+func TestTaskDefDeclaresTaskSupport(t *testing.T) {
+	tool := newFakeTaskTool(&fakeJobs{}).Tool()
+	if tool.Execution == nil || tool.Execution.TaskSupport != mcp.TaskSupportOptional {
+		t.Errorf("TaskDef 应声明 taskSupport=optional:%+v", tool.Execution)
+	}
+}

+ 17 - 8
service/agentmcp/server.go

@@ -33,21 +33,30 @@ func Tools(set string) ([]kit.Registrar, error) {
 	return nil, fmt.Errorf("未知的工具集:%s(可选 readonly / ops)", set)
 }
 
-// NewServer 创建 MCPServer 并注册工具集。
-func NewServer(set string, log *zap.Logger, maxConcurrency int) (*server.MCPServer, error) {
+// NewServer 创建 MCPServer 并注册工具集;同时返回其中的持久化长任务工具(交给 Handler 的 TaskRouter 处理任务协议)。
+func NewServer(set string, log *zap.Logger, maxConcurrency int) (*server.MCPServer, []kit.TaskTool, error) {
 	list, err := Tools(set)
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
-	s := server.NewMCPServer(
-		ServerName+"-"+set,
-		ServerVersion,
+	var tasks []kit.TaskTool
+	for _, t := range list {
+		if tt, ok := t.(kit.TaskTool); ok {
+			tasks = append(tasks, tt)
+		}
+	}
+	opts := []server.ServerOption{
 		server.WithToolCapabilities(false),
 		server.WithInputSchemaValidation(),
 		server.WithToolHandlerMiddleware(kit.Middleware(log, maxConcurrency)),
-	)
+	}
+	if len(tasks) > 0 {
+		// 声明支持以任务方式调用工具与取消(tasks.requests.tools.call、tasks.cancel);任务协议由 kit.TaskRouter 处理
+		opts = append(opts, server.WithTaskCapabilities(false, true, true))
+	}
+	s := server.NewMCPServer(ServerName+"-"+set, ServerVersion, opts...)
 	for _, t := range list {
 		t.Register(s)
 	}
-	return s, nil
+	return s, tasks, nil
 }

+ 124 - 23
service/agentmcp/tools/siteinfosync.go

@@ -6,11 +6,13 @@ import (
 	"time"
 
 	"github.com/mark3labs/mcp-go/mcp"
+	"go.mongodb.org/mongo-driver/bson/primitive"
 
 	"fwh-toolbox-server/agentmcp/kit"
 	"fwh-toolbox-server/domain"
 	"fwh-toolbox-server/repositories"
 	"fwh-toolbox-server/services"
+	"fwh-toolbox-server/task/queue/fwhQueue"
 )
 
 // 站点信息表同步(SiteInfoManageRecord)的状态判断。
@@ -65,6 +67,10 @@ func loadSiteInfoSync() (*siteInfoSync, error) {
 
 // evalSiteInfoSync 根据最近一次记录与最近一次成功记录判断同步状态
 func evalSiteInfoSync(latest, lastComplete *domain.SiteInfoUpdateRecord, now time.Time) *siteInfoSync {
+	// 最近一条已完成时它就是最近一次成功(调用方可能只传了最近一条)
+	if lastComplete == nil && latest != nil && latest.Progress == domain.ProgressComplete {
+		lastComplete = latest
+	}
 	s := &siteInfoSync{Latest: latest, LastSuccess: lastComplete, now: now}
 	switch {
 	case latest == nil:
@@ -181,31 +187,126 @@ type siteInfoRefreshArgs struct {
 	Force bool `json:"force,omitempty" jsonschema:"为 true 时:最近 24 小时内已成功同步、或上次同步疑似中断(stale)时仍重新触发。同步进行中(syncing)时任何情况下都不会重复触发"`
 }
 
-var SiteInfoRefresh = kit.Def[siteInfoRefreshArgs]{
-	Name:  "site_info_refresh",
-	Title: "触发站点信息表同步",
-	Description: "触发 ToolBox 站点信息表从 FWH 平台重新同步,立即返回,同步在主服务后台执行,耗时可能超过 1.5 小时。" +
-		"触发前先检查同步状态:同步进行中时不重复触发,返回当前进度(action=tracking);最近 24 小时内已成功同步时不触发(action=skipped_fresh);" +
-		"上次同步超过 6 小时未完成、疑似中断时不触发(action=skipped_stale);以上两种不触发的情况可用 force=true 强制触发。" +
-		"已触发时 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()
+// siteInfoTaskPoll 平台查询任务状态的间隔:同步约 1 小时,每分钟查一次足够
+const siteInfoTaskPoll = time.Minute
+
+var SiteInfoRefresh = kit.TaskDef[siteInfoRefreshArgs]{
+	Def: kit.Def[siteInfoRefreshArgs]{
+		Name:  "site_info_refresh",
+		Title: "触发站点信息表同步",
+		Description: "触发 ToolBox 站点信息表从 FWH 平台重新同步,同步在主服务后台执行,耗时约 1 小时,可能超过 1.5 小时。" +
+			"触发前会检查同步状态:同步进行中时不重复触发,改为跟踪这次同步(action=tracking);最近 24 小时内已成功同步时不触发(action=skipped_fresh);" +
+			"上次同步超过 6 小时未完成、疑似中断时不触发(action=skipped_stale);这两种不触发的情况可用 force=true 强制触发。已触发时 action=triggered。" +
+			"以长任务方式执行:触发或跟踪时立即返回“已转为后台”,同步结束后平台会把结果(完成时间、站点数、耗时,或失败原因)发给你,无需轮询;" +
+			"用户询问进度时用 site_info_sync_status。这是写操作,执行前会请求确认。",
+		ReadOnly: false,
+		// 同步中或数据较新时不重复触发,网络出错时平台重试一次不会产生第二次同步
+		Idempotent: true,
+		Handler: func(ctx context.Context, a siteInfoRefreshArgs) (*mcp.CallToolResult, error) {
+			action, s, fail := refreshSiteInfo(a.Force)
+			if fail != nil {
+				return fail, nil
+			}
+			return kit.Object(map[string]any{"action": action, "message": refreshMessages[action], "status": s.view()}), nil
+		},
+	},
+	Poll: siteInfoTaskPoll,
+	Start: func(ctx context.Context, a siteInfoRefreshArgs) (kit.TaskStart, *mcp.CallToolResult) {
+		action, s, fail := refreshSiteInfo(a.Force)
+		if fail != nil {
+			return kit.TaskStart{}, fail
+		}
+		// 每种结果都对应一条同步记录:触发的是新记录,跟踪、不触发的是最近一条记录
+		note := refreshMessages[action]
+		if action == "triggered" || action == "tracking" {
+			note += "同步结束后平台会把结果发给你,现在可以先告诉用户已提交。"
+		}
+		return kit.TaskStart{Key: s.Latest.Id.Hex(), Flag: action, Note: note}, nil
+	},
+	Status: func(ctx context.Context, ref kit.TaskRef) (kit.TaskState, error) {
+		id, err := primitive.ObjectIDFromHex(ref.Key)
 		if err != nil {
-			return kit.Upstream("ToolBox 站点信息同步记录", err), nil
+			return kit.TaskState{}, kit.ErrTaskNotFound
 		}
-		action, trigger := s.decideRefresh(a.Force)
-		if trigger {
-			if err := services.UpdateSiteInfoDate(); err != nil {
-				return kit.Upstream("ToolBox 站点信息同步", err), nil
-			}
-			if s, err = loadSiteInfoSync(); err != nil {
-				return kit.Upstream("ToolBox 站点信息同步记录", err), nil
-			}
+		rec, err := repositories.GetSiteInfoUpdateRecord(id)
+		if err != nil {
+			return kit.TaskState{}, err
 		}
-		return kit.Object(map[string]any{"action": action, "message": refreshMessages[action], "status": s.view()}), nil
+		if rec == nil {
+			return kit.TaskState{}, kit.ErrTaskNotFound
+		}
+		return siteInfoTaskState(ref.Flag, rec, time.Now()), nil
 	},
+	Cancel: func(ctx context.Context, ref kit.TaskRef) error {
+		// 只有本次调用触发的同步才真正停止;跟踪他人触发的同步时只是停止跟踪(其他会话可能还在等它)
+		if ref.Flag != "triggered" {
+			return nil
+		}
+		id, err := primitive.ObjectIDFromHex(ref.Key)
+		if err != nil {
+			return kit.ErrTaskNotFound
+		}
+		return fwhQueue.CancelSiteInfoUpdate(id)
+	},
+}
+
+// refreshSiteInfo 判断并在需要时触发同步,返回动作与触发后的状态;普通调用与任务方式调用共用
+func refreshSiteInfo(force bool) (string, *siteInfoSync, *mcp.CallToolResult) {
+	s, err := loadSiteInfoSync()
+	if err != nil {
+		return "", nil, kit.Upstream("ToolBox 站点信息同步记录", err)
+	}
+	action, trigger := s.decideRefresh(force)
+	if trigger {
+		if err := services.UpdateSiteInfoDate(); err != nil {
+			return "", nil, kit.Upstream("ToolBox 站点信息同步", err)
+		}
+		if s, err = loadSiteInfoSync(); err != nil {
+			return "", nil, kit.Upstream("ToolBox 站点信息同步记录", err)
+		}
+	}
+	return action, s, nil
+}
+
+// siteInfoTaskState 把同步记录翻译为任务状态。flag 为发起任务时的动作:
+// skipped_* 时任务立即完成(结果是不触发的原因);triggered / tracking 时跟随这条同步记录
+func siteInfoTaskState(flag string, rec *domain.SiteInfoUpdateRecord, now time.Time) kit.TaskState {
+	st := kit.TaskState{CreatedAt: rec.CreateTime}
+	if flag == "skipped_fresh" || flag == "skipped_stale" {
+		st.Status, st.Message = mcp.TaskStatusCompleted, "未触发同步"
+		st.Result = kit.Object(map[string]any{"action": flag, "message": refreshMessages[flag], "status": evalSiteInfoSync(rec, nil, now).view()})
+		return st
+	}
+	elapsed := now.Sub(rec.CreateTime)
+	switch rec.Progress {
+	case domain.ProgressPending, domain.ProgressRunning:
+		if elapsed > syncStaleAfter {
+			st.Status = mcp.TaskStatusFailed
+			st.Message = fmt.Sprintf("同步已 %s 仍未完成,疑似中断", fmtHours(elapsed))
+			st.Result = kit.Failf("站点信息同步已请求 %s 仍未完成,超过 %s,疑似中断(如主服务重启)。可以告诉用户,确认后用 site_info_refresh 并设置 force=true 重新同步。",
+				fmtHours(elapsed), fmtHours(syncStaleAfter))
+			return st
+		}
+		st.Status = mcp.TaskStatusWorking
+		if rec.Progress == domain.ProgressPending {
+			st.Message = fmt.Sprintf("排队中,已等待 %s", fmtHours(elapsed))
+		} else {
+			st.Message = fmt.Sprintf("同步中,已进行 %s", fmtHours(elapsed))
+		}
+	case domain.ProgressComplete:
+		st.Status, st.Message = mcp.TaskStatusCompleted, "同步完成"
+		st.Result = kit.Object(map[string]any{
+			"message":     fmt.Sprintf("站点信息表同步完成:%d 个站点,耗时 %s,完成于 %s(北京时间)。", rec.Total, fmtHours(rec.UpdateTime.Sub(rec.CreateTime)), fmtBeijing(rec.UpdateTime)),
+			"requestedAt": fmtBeijing(rec.CreateTime),
+			"completedAt": fmtBeijing(rec.UpdateTime),
+			"total":       rec.Total,
+		})
+	case domain.ProgressCancelled:
+		st.Status, st.Message = mcp.TaskStatusCancelled, "同步已取消"
+		st.Result = kit.Failf("站点信息同步已取消。")
+	default: // error / failed
+		st.Status, st.Message = mcp.TaskStatusFailed, "同步失败"
+		st.Result = kit.Failf("站点信息同步失败(主服务同步任务出错,详情见主服务日志)。可以稍后用 site_info_refresh 重新同步。")
+	}
+	return st
 }

+ 35 - 0
service/agentmcp/tools/siteinfosync_test.go

@@ -4,6 +4,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/mark3labs/mcp-go/mcp"
 	"go.mongodb.org/mongo-driver/bson/primitive"
 
 	"fwh-toolbox-server/domain"
@@ -55,3 +56,37 @@ func TestSiteInfoSyncDecide(t *testing.T) {
 		}
 	}
 }
+
+func TestSiteInfoTaskState(t *testing.T) {
+	now := time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC)
+	ago := func(h float64) time.Time { return now.Add(-time.Duration(h * float64(time.Hour))) }
+
+	cases := []struct {
+		name    string
+		flag    string
+		rec     *domain.SiteInfoUpdateRecord
+		status  mcp.TaskStatus
+		isError bool
+	}{
+		{"排队中", "triggered", syncRecord(domain.ProgressPending, ago(0.1), ago(0.1)), mcp.TaskStatusWorking, false},
+		{"同步中", "tracking", syncRecord(domain.ProgressRunning, ago(1), ago(1)), mcp.TaskStatusWorking, false},
+		{"超过 6 小时疑似中断", "triggered", syncRecord(domain.ProgressRunning, ago(7), ago(7)), mcp.TaskStatusFailed, true},
+		{"完成", "triggered", syncRecord(domain.ProgressComplete, ago(2), ago(1)), mcp.TaskStatusCompleted, false},
+		{"失败", "triggered", syncRecord(domain.ProgressError, ago(2), ago(2)), mcp.TaskStatusFailed, true},
+		{"取消", "triggered", syncRecord(domain.ProgressCancelled, ago(2), ago(2)), mcp.TaskStatusCancelled, true},
+		{"未触发:数据较新,立即完成", "skipped_fresh", syncRecord(domain.ProgressComplete, ago(3), ago(2)), mcp.TaskStatusCompleted, false},
+		{"未触发:疑似中断,立即完成", "skipped_stale", syncRecord(domain.ProgressRunning, ago(7), ago(7)), mcp.TaskStatusCompleted, false},
+	}
+	for _, c := range cases {
+		st := siteInfoTaskState(c.flag, c.rec, now)
+		if st.Status != c.status {
+			t.Errorf("%s: status = %s,期望 %s", c.name, st.Status, c.status)
+		}
+		if st.Status.IsTerminal() && (st.Result == nil || st.Result.IsError != c.isError) {
+			t.Errorf("%s: 终态结果不符合预期(isError 期望 %v):%+v", c.name, c.isError, st.Result)
+		}
+		if !st.Status.IsTerminal() && st.Message == "" {
+			t.Errorf("%s: 进行中应有进度说明", c.name)
+		}
+	}
+}

+ 2 - 2
service/cmd/agent-mcp/main.go

@@ -47,7 +47,7 @@ func main() {
 		log.Fatal("[agent-mcp] 初始化失败", zap.Error(err))
 	}
 
-	s, err := agentmcp.NewServer(*set, log, settings.MaxConcurrency)
+	s, tasks, err := agentmcp.NewServer(*set, log, settings.MaxConcurrency)
 	if err != nil {
 		log.Fatal("[agent-mcp] 创建 MCP Server 失败", zap.Error(err))
 	}
@@ -56,7 +56,7 @@ func main() {
 	defer stop()
 
 	log.Info("[agent-mcp] 启动", zap.String("set", *set), zap.String("version", agentmcp.ServerVersion), zap.Int("maxConcurrency", settings.MaxConcurrency))
-	if err := agentmcp.Serve(ctx, addr, agentmcp.Handler(s, settings.Token), log); err != nil {
+	if err := agentmcp.Serve(ctx, addr, agentmcp.Handler(s, settings.Token, tasks, log), log); err != nil {
 		log.Fatal("[agent-mcp] HTTP 服务异常退出", zap.Error(err))
 	}
 	log.Info("[agent-mcp] 已退出")