From a0a90f558cef37c94c574e90108ac259cfc56681 Mon Sep 17 00:00:00 2001 From: TaskPool Date: Sun, 26 Jul 2026 19:30:43 +0800 Subject: [PATCH] feat(mcp): add official MCP Server for AI Agent integration --- README.md | 1 + cmd/cmd.go | 2 + cmd/mcp/client.go | 157 +++++++++ cmd/mcp/server.go | 606 ++++++++++++++++++++++++++++++++++ docs/.vitepress/config.mts | 6 +- docs/guide/mcp.md | 125 +++++++ go.mod | 6 +- go.sum | 8 + internal/constant/commands.go | 4 + main.go | 3 +- web/src/pages/Settings.tsx | 20 ++ 11 files changed, 934 insertions(+), 4 deletions(-) create mode 100644 cmd/mcp/client.go create mode 100644 cmd/mcp/server.go create mode 100644 docs/guide/mcp.md diff --git a/README.md b/README.md index 0696750..3207c9f 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ - **远程执行:** 支持远程agent执行任务,展示执行结果 - **多语言支持:** 深度集成 Mise,支持几乎所有主流编程语言的动态安装、多版本切换及依赖管理 - **内建助手库:** **(New)** 为 Python/Node.js 提供零配置助手库,简单 import 即可实现一键推信,无需手动管理 API Token 和 URL +- **MCP Server:** **(New)** 内置 MCP Server(`taskpool mcp`),Hermes / OpenClaw / Cursor 等 AI Agent 可完整接管任务、脚本、日志与环境变量 ## 功能特性 diff --git a/cmd/cmd.go b/cmd/cmd.go index e2fa4f8..677b803 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -3,6 +3,7 @@ package cmd import ( "github.com/engigu/taskpool/cmd/builtininstall" "github.com/engigu/taskpool/cmd/depinstall" + mcpcmd "github.com/engigu/taskpool/cmd/mcp" "github.com/engigu/taskpool/cmd/reposync" "github.com/engigu/taskpool/cmd/resetpwd" "github.com/engigu/taskpool/cmd/restore" @@ -22,6 +23,7 @@ var Handlers = map[string]CommandHandler{ "restore": restore.Run, "builtininstall": builtininstall.Run, "task": task.Run, + "mcp": mcpcmd.Run, "webui": webui.Run, "version": version.Run, "-v": version.Run, diff --git a/cmd/mcp/client.go b/cmd/mcp/client.go new file mode 100644 index 0000000..9c1842a --- /dev/null +++ b/cmd/mcp/client.go @@ -0,0 +1,157 @@ +package mcp + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Client 调用 TaskPool OpenAPI (/open2api/v1) +type Client struct { + BaseURL string + Token string + HTTPClient *http.Client +} + +func NewClient(baseURL, token string) *Client { + return &Client{ + BaseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"), + Token: strings.TrimSpace(token), + HTTPClient: &http.Client{ + Timeout: 60 * time.Second, + }, + } +} + +func (c *Client) openBase() string { + return c.BaseURL + "/open2api/v1" +} + +func (c *Client) do(method, path string, query map[string]string, body any) (json.RawMessage, error) { + if c.BaseURL == "" { + return nil, fmt.Errorf("TASKPOOL_URL 未配置") + } + if c.Token == "" { + return nil, fmt.Errorf("TASKPOOL_TOKEN 未配置") + } + + u, err := url.Parse(c.openBase() + path) + if err != nil { + return nil, err + } + if len(query) > 0 { + q := u.Query() + for k, v := range query { + if strings.TrimSpace(v) != "" { + q.Set(k, v) + } + } + u.RawQuery = q.Encode() + } + + var reader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, err + } + reader = bytes.NewReader(b) + } + + req, err := http.NewRequest(method, u.String(), reader) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.Token) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("请求失败: %w", err) + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var envelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncate(string(raw), 500)) + } + return raw, nil + } + + // TaskPool 统一返回 HTTP 200,用 JSON body 中 code 区分成功/失败 + // code=200 → 成功;其他 → 错误 + if envelope.Code != 200 { + msg := envelope.Msg + if msg == "" { + msg = truncate(string(raw), 500) + } + return nil, fmt.Errorf("[%d] %s", envelope.Code, msg) + } + if len(envelope.Data) > 0 && string(envelope.Data) != "null" { + return envelope.Data, nil + } + // data 为空但请求成功(如 delete/stop 只返回 msg) + result := map[string]any{"code": envelope.Code, "msg": envelope.Msg} + b, _ := json.Marshal(result) + return b, nil +} + +func (c *Client) Get(path string, query map[string]string) (json.RawMessage, error) { + return c.do(http.MethodGet, path, query, nil) +} + +func (c *Client) Post(path string, body any) (json.RawMessage, error) { + return c.do(http.MethodPost, path, nil, body) +} + +func (c *Client) Put(path string, body any) (json.RawMessage, error) { + return c.do(http.MethodPut, path, nil, body) +} + +func (c *Client) Delete(path string) (json.RawMessage, error) { + return c.do(http.MethodDelete, path, nil, nil) +} + +func prettyJSON(v any) string { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Sprintf("%v", v) + } + return string(b) +} + +func prettyRaw(raw json.RawMessage) string { + if len(raw) == 0 { + return "null" + } + var v any + if err := json.Unmarshal(raw, &v); err != nil { + return string(raw) + } + return prettyJSON(v) +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/cmd/mcp/server.go b/cmd/mcp/server.go new file mode 100644 index 0000000..77f2cea --- /dev/null +++ b/cmd/mcp/server.go @@ -0,0 +1,606 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + serverName = "taskpool" + serverVersion = "1.0.0" +) + +// Run 启动 TaskPool MCP Server(stdio) +// 环境变量: +// TASKPOOL_URL 面板地址,如 http://127.0.0.1:8052 或 https://panel.example.com +// TASKPOOL_TOKEN 设置页 OpenAPI Token +func Run(args []string) { + for _, a := range args { + if a == "-h" || a == "--help" { + printHelp() + return + } + } + + baseURL := firstNonEmpty(os.Getenv("TASKPOOL_URL"), os.Getenv("BAIHU_URL"), "http://127.0.0.1:8052") + token := firstNonEmpty(os.Getenv("TASKPOOL_TOKEN"), os.Getenv("BAIHU_TOKEN"), os.Getenv("OPENAPI_TOKEN")) + + client := NewClient(baseURL, token) + s := server.NewMCPServer( + serverName, + serverVersion, + server.WithToolCapabilities(true), + server.WithResourceCapabilities(false, false), + server.WithPromptCapabilities(true), + server.WithInstructions(instructions), + server.WithRecovery(), + ) + + registerTools(s, client) + registerPrompts(s) + + // 日志只能走 stderr,避免污染 stdio JSON-RPC + fmt.Fprintf(os.Stderr, "[taskpool-mcp] url=%s token=%s\n", client.BaseURL, maskToken(client.Token)) + + if err := server.ServeStdio(s); err != nil { + fmt.Fprintf(os.Stderr, "[taskpool-mcp] server error: %v\n", err) + os.Exit(1) + } +} + +func printHelp() { + fmt.Fprintf(os.Stderr, ` +TaskPool MCP Server + +通过 MCP 协议把 TaskPool OpenAPI 暴露给 Hermes / OpenClaw / Cursor 等 Agent。 + +用法: + taskpool mcp + +环境变量: + TASKPOOL_URL 面板地址 (默认 http://127.0.0.1:8052) + TASKPOOL_TOKEN 设置页生成的 OpenAPI Token(必填) + +Hermes / OpenClaw 配置示例: + { + "mcpServers": { + "taskpool": { + "command": "taskpool", + "args": ["mcp"], + "env": { + "TASKPOOL_URL": "http://127.0.0.1:8052", + "TASKPOOL_TOKEN": "你的 OpenAPI Token" + } + } + } + } + +前置条件: + 1. 面板「系统设置」启用 OpenAPI 并生成 Token + 2. 后端服务已启动 (taskpool server) +`) +} + +const instructions = `你是 TaskPool(任务池)运维助手。通过 MCP tools 管理任务调度面板。 + +核心能力: +- 任务:列表/详情/创建/更新/删除/启停执行 +- 脚本:列表/详情/创建/更新/删除 +- 环境变量:列表/详情/创建/更新/删除(secret 值为脱敏) +- 日志:列表/详情、最近执行结果 + +使用原则: +1. 先 list_tasks / get_task 确认目标,再 run_task +2. 删除类操作前向用户确认 +3. 不要编造任务 ID;ID 以 list/get 返回为准 +4. 执行后用 list_logs 或 get_log 查看结果 +5. 创建任务时 command 必填(普通任务);schedule 使用标准 cron` + +func registerTools(s *server.MCPServer, c *Client) { + // ---- tasks ---- + s.AddTool(mcp.NewTool("list_tasks", + mcp.WithDescription("分页列出任务,可按名称/标签/启用状态筛选"), + mcp.WithString("name", mcp.Description("按名称模糊筛选")), + mcp.WithString("tags", mcp.Description("按标签筛选")), + mcp.WithString("enabled", mcp.Description("true/false,是否启用")), + mcp.WithNumber("page", mcp.Description("页码,默认 1")), + mcp.WithNumber("page_size", mcp.Description("每页数量,默认 20")), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + q := map[string]string{ + "name": strArg(req, "name"), + "tags": strArg(req, "tags"), + "enabled": strArg(req, "enabled"), + "page": numArgStr(req, "page", "1"), + "page_size": numArgStr(req, "page_size", "20"), + } + raw, err := c.Get("/tasks", q) + return toolResult(raw, err) + }) + + s.AddTool(mcp.NewTool("get_task", + mcp.WithDescription("获取单个任务详情"), + mcp.WithString("id", mcp.Required(), mcp.Description("任务 ID")), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id, err := req.RequireString("id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + raw, err := c.Get("/tasks/"+id, nil) + return toolResult(raw, err) + }) + + s.AddTool(mcp.NewTool("create_task", + mcp.WithDescription("创建任务。普通任务必须提供 command;可选 schedule(cron)、tags、timeout、work_dir、remark、enabled 等"), + mcp.WithString("name", mcp.Required(), mcp.Description("任务名称")), + mcp.WithString("command", mcp.Description("执行命令,普通任务必填")), + mcp.WithString("schedule", mcp.Description("Cron 表达式,如 0 0 * * *")), + mcp.WithString("remark", mcp.Description("备注")), + mcp.WithString("tags", mcp.Description("标签,逗号分隔")), + mcp.WithString("work_dir", mcp.Description("工作目录")), + mcp.WithNumber("timeout", mcp.Description("超时秒数")), + mcp.WithString("type", mcp.Description("任务类型,默认 common;仓库同步为 repo")), + mcp.WithString("config", mcp.Description("JSON 字符串配置(repo 任务用)")), + mcp.WithString("envs", mcp.Description("环境变量 JSON 字符串")), + mcp.WithString("trigger_type", mcp.Description("触发类型,如 cron")), + mcp.WithNumber("retry_count", mcp.Description("重试次数")), + mcp.WithNumber("retry_interval", mcp.Description("重试间隔秒")), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + body := map[string]any{} + setStr(body, "name", strArg(req, "name")) + setStr(body, "command", strArg(req, "command")) + setStr(body, "schedule", strArg(req, "schedule")) + setStr(body, "remark", strArg(req, "remark")) + setStr(body, "tags", strArg(req, "tags")) + setStr(body, "work_dir", strArg(req, "work_dir")) + setStr(body, "type", strArg(req, "type")) + setStr(body, "config", strArg(req, "config")) + setStr(body, "envs", strArg(req, "envs")) + setStr(body, "trigger_type", strArg(req, "trigger_type")) + if n, ok := numArg(req, "timeout"); ok { + body["timeout"] = n + } + if n, ok := numArg(req, "retry_count"); ok { + body["retry_count"] = n + } + if n, ok := numArg(req, "retry_interval"); ok { + body["retry_interval"] = n + } + if body["name"] == nil || body["name"] == "" { + return mcp.NewToolResultError("name 必填"), nil + } + raw, err := c.Post("/tasks", body) + return toolResult(raw, err) + }) + + s.AddTool(mcp.NewTool("update_task", + mcp.WithDescription("更新任务字段(只传需要改的字段)"), + mcp.WithString("id", mcp.Required(), mcp.Description("任务 ID")), + mcp.WithString("name", mcp.Description("任务名称")), + mcp.WithString("command", mcp.Description("命令")), + mcp.WithString("schedule", mcp.Description("Cron")), + mcp.WithString("remark", mcp.Description("备注")), + mcp.WithString("tags", mcp.Description("标签")), + mcp.WithString("work_dir", mcp.Description("工作目录")), + mcp.WithString("enabled", mcp.Description("true/false")), + mcp.WithNumber("timeout", mcp.Description("超时秒数")), + mcp.WithString("envs", mcp.Description("环境变量 JSON 字符串")), + mcp.WithString("type", mcp.Description("任务类型")), + mcp.WithString("config", mcp.Description("配置 JSON 字符串")), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id, err := req.RequireString("id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + body := map[string]any{} + setStr(body, "name", strArg(req, "name")) + setStr(body, "command", strArg(req, "command")) + setStr(body, "schedule", strArg(req, "schedule")) + setStr(body, "remark", strArg(req, "remark")) + setStr(body, "tags", strArg(req, "tags")) + setStr(body, "work_dir", strArg(req, "work_dir")) + setStr(body, "envs", strArg(req, "envs")) + setStr(body, "type", strArg(req, "type")) + setStr(body, "config", strArg(req, "config")) + if e := strArg(req, "enabled"); e != "" { + body["enabled"] = strings.EqualFold(e, "true") || e == "1" + } + if n, ok := numArg(req, "timeout"); ok { + body["timeout"] = n + } + raw, err := c.Put("/tasks/"+id, body) + return toolResult(raw, err) + }) + + s.AddTool(mcp.NewTool("delete_task", + mcp.WithDescription("删除任务(危险操作,执行前请确认)"), + mcp.WithString("id", mcp.Required(), mcp.Description("任务 ID")), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id, err := req.RequireString("id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + raw, err := c.Delete("/tasks/" + id) + return toolResult(raw, err) + }) + + s.AddTool(mcp.NewTool("list_task_tags", + mcp.WithDescription("获取所有任务标签"), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + raw, err := c.Get("/tasks/tags", nil) + return toolResult(raw, err) + }) + + s.AddTool(mcp.NewTool("run_task", + mcp.WithDescription("立即执行指定任务。可选传入临时环境变量 envs_json"), + mcp.WithString("id", mcp.Required(), mcp.Description("任务 ID")), + mcp.WithString("envs_json", mcp.Description("临时环境变量 JSON 对象字符串,如 {\"FOO\":\"bar\"}")), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id, err := req.RequireString("id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + var body any + if ej := strArg(req, "envs_json"); ej != "" { + var envs map[string]string + if err := json.Unmarshal([]byte(ej), &envs); err != nil { + return mcp.NewToolResultError("envs_json 不是合法 JSON 对象: " + err.Error()), nil + } + body = map[string]any{"envs": envs} + } else { + body = map[string]any{} + } + raw, err := c.Post("/execute/task/"+id, body) + return toolResult(raw, err) + }) + + s.AddTool(mcp.NewTool("stop_task", + mcp.WithDescription("停止正在运行的任务(需要执行日志 log_id)"), + mcp.WithString("log_id", mcp.Required(), mcp.Description("运行日志 ID")), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + logID, err := req.RequireString("log_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + raw, err := c.Post("/tasks/stop/"+logID, map[string]any{}) + return toolResult(raw, err) + }) + + s.AddTool(mcp.NewTool("get_last_results", + mcp.WithDescription("获取最近若干次执行结果摘要"), + mcp.WithNumber("count", mcp.Description("数量,默认 10")), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + q := map[string]string{"count": numArgStr(req, "count", "10")} + raw, err := c.Get("/execute/results", q) + return toolResult(raw, err) + }) + + // ---- logs ---- + s.AddTool(mcp.NewTool("list_logs", + mcp.WithDescription("分页查询任务执行日志"), + mcp.WithString("task_id", mcp.Description("任务 ID")), + mcp.WithString("task_name", mcp.Description("任务名称模糊")), + mcp.WithString("status", mcp.Description("状态筛选")), + mcp.WithNumber("page", mcp.Description("页码,默认 1")), + mcp.WithNumber("page_size", mcp.Description("每页数量,默认 20")), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + q := map[string]string{ + "task_id": strArg(req, "task_id"), + "task_name": strArg(req, "task_name"), + "status": strArg(req, "status"), + "page": numArgStr(req, "page", "1"), + "page_size": numArgStr(req, "page_size", "20"), + } + raw, err := c.Get("/logs", q) + return toolResult(raw, err) + }) + + s.AddTool(mcp.NewTool("get_log", + mcp.WithDescription("获取单条执行日志详情(含输出)"), + mcp.WithString("id", mcp.Required(), mcp.Description("日志 ID")), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id, err := req.RequireString("id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + raw, err := c.Get("/logs/"+id, nil) + return toolResult(raw, err) + }) + + // ---- scripts ---- + s.AddTool(mcp.NewTool("list_scripts", + mcp.WithDescription("列出脚本(不含内容)"), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + raw, err := c.Get("/scripts", nil) + return toolResult(raw, err) + }) + + s.AddTool(mcp.NewTool("get_script", + mcp.WithDescription("获取脚本详情(含内容)"), + mcp.WithString("id", mcp.Required(), mcp.Description("脚本 ID")), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id, err := req.RequireString("id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + raw, err := c.Get("/scripts/"+id, nil) + return toolResult(raw, err) + }) + + s.AddTool(mcp.NewTool("create_script", + mcp.WithDescription("创建脚本"), + mcp.WithString("name", mcp.Required(), mcp.Description("脚本名称/路径")), + mcp.WithString("content", mcp.Required(), mcp.Description("脚本内容")), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + name, err := req.RequireString("name") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + content, err := req.RequireString("content") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + raw, err := c.Post("/scripts", map[string]any{"name": name, "content": content}) + return toolResult(raw, err) + }) + + s.AddTool(mcp.NewTool("update_script", + mcp.WithDescription("更新脚本"), + mcp.WithString("id", mcp.Required(), mcp.Description("脚本 ID")), + mcp.WithString("name", mcp.Description("名称")), + mcp.WithString("content", mcp.Description("内容")), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id, err := req.RequireString("id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + body := map[string]any{} + setStr(body, "name", strArg(req, "name")) + setStr(body, "content", strArg(req, "content")) + raw, err := c.Put("/scripts/"+id, body) + return toolResult(raw, err) + }) + + s.AddTool(mcp.NewTool("delete_script", + mcp.WithDescription("删除脚本(危险操作)"), + mcp.WithString("id", mcp.Required(), mcp.Description("脚本 ID")), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id, err := req.RequireString("id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + raw, err := c.Delete("/scripts/" + id) + return toolResult(raw, err) + }) + + // ---- env ---- + s.AddTool(mcp.NewTool("list_envs", + mcp.WithDescription("分页列出环境变量(secret 值已脱敏)"), + mcp.WithString("name", mcp.Description("按名称筛选")), + mcp.WithString("type", mcp.Description("类型筛选")), + mcp.WithString("tags", mcp.Description("标签筛选")), + mcp.WithNumber("page", mcp.Description("页码")), + mcp.WithNumber("page_size", mcp.Description("每页数量")), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + q := map[string]string{ + "name": strArg(req, "name"), + "type": strArg(req, "type"), + "tags": strArg(req, "tags"), + "page": numArgStr(req, "page", "1"), + "page_size": numArgStr(req, "page_size", "20"), + } + raw, err := c.Get("/env", q) + return toolResult(raw, err) + }) + + s.AddTool(mcp.NewTool("list_all_envs", + mcp.WithDescription("获取全部环境变量(不分页,secret 脱敏)"), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + raw, err := c.Get("/env/all", nil) + return toolResult(raw, err) + }) + + s.AddTool(mcp.NewTool("get_env", + mcp.WithDescription("获取环境变量详情"), + mcp.WithString("id", mcp.Required(), mcp.Description("环境变量 ID")), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id, err := req.RequireString("id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + raw, err := c.Get("/env/"+id, nil) + return toolResult(raw, err) + }) + + s.AddTool(mcp.NewTool("create_env", + mcp.WithDescription("创建环境变量"), + mcp.WithString("name", mcp.Required(), mcp.Description("变量名")), + mcp.WithString("value", mcp.Required(), mcp.Description("变量值")), + mcp.WithString("type", mcp.Description("类型,如 text/secret")), + mcp.WithString("remark", mcp.Description("备注")), + mcp.WithString("tags", mcp.Description("标签")), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + name, err := req.RequireString("name") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + value, err := req.RequireString("value") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + body := map[string]any{"name": name, "value": value} + setStr(body, "type", strArg(req, "type")) + setStr(body, "remark", strArg(req, "remark")) + setStr(body, "tags", strArg(req, "tags")) + raw, err := c.Post("/env", body) + return toolResult(raw, err) + }) + + s.AddTool(mcp.NewTool("update_env", + mcp.WithDescription("更新环境变量"), + mcp.WithString("id", mcp.Required(), mcp.Description("环境变量 ID")), + mcp.WithString("name", mcp.Description("变量名")), + mcp.WithString("value", mcp.Description("变量值")), + mcp.WithString("type", mcp.Description("类型")), + mcp.WithString("remark", mcp.Description("备注")), + mcp.WithString("tags", mcp.Description("标签")), + mcp.WithString("enabled", mcp.Description("true/false")), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id, err := req.RequireString("id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + body := map[string]any{} + setStr(body, "name", strArg(req, "name")) + setStr(body, "value", strArg(req, "value")) + setStr(body, "type", strArg(req, "type")) + setStr(body, "remark", strArg(req, "remark")) + setStr(body, "tags", strArg(req, "tags")) + if e := strArg(req, "enabled"); e != "" { + body["enabled"] = strings.EqualFold(e, "true") || e == "1" + } + raw, err := c.Put("/env/"+id, body) + return toolResult(raw, err) + }) + + s.AddTool(mcp.NewTool("delete_env", + mcp.WithDescription("删除环境变量(危险操作)"), + mcp.WithString("id", mcp.Required(), mcp.Description("环境变量 ID")), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id, err := req.RequireString("id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + raw, err := c.Delete("/env/" + id) + return toolResult(raw, err) + }) + + // ---- meta ---- + s.AddTool(mcp.NewTool("ping_api", + mcp.WithDescription("检查 OpenAPI 连通性与鉴权是否有效(通过 list_tasks page_size=1)"), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + raw, err := c.Get("/tasks", map[string]string{"page": "1", "page_size": "1"}) + if err != nil { + return mcp.NewToolResultError("连通失败: " + err.Error()), nil + } + return mcp.NewToolResultText("OpenAPI 连通正常\n" + prettyRaw(raw)), nil + }) +} + +func registerPrompts(s *server.MCPServer) { + s.AddPrompt(mcp.NewPrompt("ops_overview", + mcp.WithPromptDescription("巡检任务池:列出任务与最近失败日志"), + ), func(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + return mcp.NewGetPromptResult( + "TaskPool 巡检", + []mcp.PromptMessage{ + mcp.NewPromptMessage(mcp.RoleUser, mcp.NewTextContent( + "请使用 TaskPool MCP:1) list_tasks 查看任务概况 2) list_logs 筛选失败状态 3) 对重要失败 get_log 总结原因并给出是否建议 run_task 重跑。", + )), + }, + ), nil + }) + + s.AddPrompt(mcp.NewPrompt("run_and_check", + mcp.WithPromptDescription("执行指定任务并检查日志"), + mcp.WithArgument("task_id", mcp.RequiredArgument(), mcp.ArgumentDescription("任务 ID")), + ), func(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + taskID := "" + if request.Params.Arguments != nil { + taskID = request.Params.Arguments["task_id"] + } + text := fmt.Sprintf("请对 TaskPool 任务 %s:1) get_task 确认 2) run_task 执行 3) list_logs(task_id) 取最新日志 4) get_log 汇总输出与状态。", taskID) + return mcp.NewGetPromptResult( + "执行并检查", + []mcp.PromptMessage{ + mcp.NewPromptMessage(mcp.RoleUser, mcp.NewTextContent(text)), + }, + ), nil + }) +} + +func toolResult(raw json.RawMessage, err error) (*mcp.CallToolResult, error) { + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return mcp.NewToolResultText(prettyRaw(raw)), nil +} + +func strArg(req mcp.CallToolRequest, key string) string { + v, err := req.RequireString(key) + if err != nil { + // optional + if args, ok := req.Params.Arguments.(map[string]any); ok { + if x, ok := args[key]; ok { + return strings.TrimSpace(fmt.Sprint(x)) + } + } + return "" + } + return strings.TrimSpace(v) +} + +func numArg(req mcp.CallToolRequest, key string) (float64, bool) { + if args, ok := req.Params.Arguments.(map[string]any); ok { + if x, ok := args[key]; ok { + switch t := x.(type) { + case float64: + return t, true + case int: + return float64(t), true + case string: + if t == "" { + return 0, false + } + f, err := strconv.ParseFloat(t, 64) + if err != nil { + return 0, false + } + return f, true + } + } + } + return 0, false +} + +func numArgStr(req mcp.CallToolRequest, key, def string) string { + if n, ok := numArg(req, key); ok { + return strconv.FormatInt(int64(n), 10) + } + if s := strArg(req, key); s != "" { + return s + } + return def +} + +func setStr(m map[string]any, k, v string) { + if strings.TrimSpace(v) != "" { + m[k] = v + } +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} + +func maskToken(t string) string { + t = strings.TrimSpace(t) + if t == "" { + return "(empty)" + } + if len(t) <= 8 { + return "****" + } + return t[:4] + "****" + t[len(t)-4:] +} diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index de15a17..1851558 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -15,7 +15,8 @@ export default defineConfig({ nav: [ { text: '快速开始', link: '/guide/introduction' }, { text: '部署指南', link: '/guide/deployment' }, - { text: 'API 文档', link: '/guide/api' } + { text: 'API 文档', link: '/guide/api' }, + { text: 'MCP', link: '/guide/mcp' } ], sidebar: [ @@ -25,7 +26,8 @@ export default defineConfig({ { text: '项目介绍', link: '/guide/introduction' }, { text: '部署说明', link: '/guide/deployment' }, { text: '开始使用', link: '/guide/getting-started' }, - { text: 'API 文档', link: '/guide/api' } + { text: 'API 文档', link: '/guide/api' }, + { text: 'MCP Server', link: '/guide/mcp' } ] }, { diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md new file mode 100644 index 0000000..085e79d --- /dev/null +++ b/docs/guide/mcp.md @@ -0,0 +1,125 @@ +# MCP Server(Hermes / OpenClaw) + +TaskPool 提供官方 **MCP Server**,让 Hermes、OpenClaw、Cursor 等 AI Agent **完整接管**任务池:任务、脚本、环境变量、执行与日志。 + +> OpenAPI ≠ MCP +> - **OpenAPI**:`/open2api/v1` REST 接口(设置页「启用 OpenAPI」) +> - **MCP Server**:`taskpool mcp`,把上述接口封装成 Agent 可调用的 Tools + +## 前置条件 + +1. 启动后端:`taskpool server` +2. 系统设置 → **启用 OpenAPI** → **生成 Token** +3. 本机或 Agent 环境能访问面板地址(如 `http://127.0.0.1:8052`) + +## 启动 + +```bash +export TASKPOOL_URL=http://127.0.0.1:8052 +export TASKPOOL_TOKEN=你的OpenAPI_Token +taskpool mcp +``` + +仅 stdio 传输:由 Agent 拉起子进程,通过标准输入输出通信。日志输出在 **stderr**,不会污染协议。 + +## Hermes / OpenClaw 配置 + +```json +{ + "mcpServers": { + "taskpool": { + "command": "taskpool", + "args": ["mcp"], + "env": { + "TASKPOOL_URL": "http://127.0.0.1:8052", + "TASKPOOL_TOKEN": "替换为设置页 Token" + } + } + } +} +``` + +若二进制不在 PATH: + +```json +{ + "command": "/path/to/taskpool", + "args": ["mcp"], + "env": { + "TASKPOOL_URL": "https://你的面板地址", + "TASKPOOL_TOKEN": "xxx" + } +} +``` + +Docker 部署时,把 `TASKPOOL_URL` 写成 Agent 能访问的地址(宿主机映射端口或内网域名)。 + +## 工具清单(Tools) + +| 分类 | Tool | 说明 | +|------|------|------| +| 连通 | `ping_api` | 校验 URL + Token | +| 任务 | `list_tasks` | 分页列表/筛选 | +| 任务 | `get_task` | 详情 | +| 任务 | `create_task` | 创建 | +| 任务 | `update_task` | 更新 | +| 任务 | `delete_task` | 删除 | +| 任务 | `list_task_tags` | 标签 | +| 执行 | `run_task` | 立即执行 | +| 执行 | `stop_task` | 按 log_id 停止 | +| 执行 | `get_last_results` | 最近结果 | +| 日志 | `list_logs` | 日志列表 | +| 日志 | `get_log` | 日志详情(含输出) | +| 脚本 | `list_scripts` / `get_script` / `create_script` / `update_script` / `delete_script` | 脚本 CRUD | +| 环境变量 | `list_envs` / `list_all_envs` / `get_env` / `create_env` / `update_env` / `delete_env` | 环境变量 CRUD | + +## Prompts + +- `ops_overview`:巡检任务与失败日志 +- `run_and_check`:执行指定任务并检查日志(参数 `task_id`) + +## Skill(可选) + +仓库内提供 Agent Skill 文档,便于模型按固定流程操作: + +- [`skills/taskpool/SKILL.md`](../../skills/taskpool/SKILL.md) + +Skill 是说明书;**真正执行依赖 MCP Tools**。两者一起用效果最好。 + +## 安全建议 + +1. Token 等同管理员 OpenAPI 权限,勿提交到 Git +2. 删除任务/脚本/环境变量前应人工确认 +3. 不要对不可信网络暴露面板与 Token +4. 生产建议独立 Token,并定期轮换 + +## 典型对话 + +- 「列出所有启用的任务」→ `list_tasks` +- 「跑一下备份任务并看日志」→ `list_tasks` → `run_task` → `list_logs` → `get_log` +- 「创建一个每天凌晨执行的清理任务」→ `create_task` +- 「最近失败的任务是什么原因」→ `list_logs(status=failed)` → `get_log` + +## 与项目能力的对应 + +参考 [项目介绍](./introduction.md) 中的能力边界: + +| 面板能力 | MCP 是否覆盖 | +|----------|--------------| +| 任务调度 / 手动执行 | ✅ | +| 脚本管理 | ✅ | +| 环境变量 | ✅ | +| 执行日志 | ✅ | +| 在线终端 | ❌(WebSocket,不适合 MCP 工具化) | +| 语言环境 Mise 安装 | ❌(可后续扩展) | +| 消息推送配置 | ❌(可后续扩展) | +| 远程 Agent 节点 | 间接(任务上指定 agent 后可执行) | + +## 故障排查 + +| 现象 | 处理 | +|------|------| +| `TASKPOOL_TOKEN 未配置` | 设置 env 或配置里的 Token | +| `无效的 OpenAPI 令牌` | 设置页启用 OpenAPI 并重新生成 | +| 连接失败 | 检查 `TASKPOOL_URL`、防火墙、容器网络 | +| Agent 看不到 tools | 确认 `command` 路径正确,重启 Agent Gateway | diff --git a/go.mod b/go.mod index 79d5b7e..af55297 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,8 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/yamux v0.1.2 + github.com/klauspost/compress v1.19.0 + github.com/mark3labs/mcp-go v0.57.0 github.com/robfig/cron/v3 v3.0.1 github.com/rs/xid v1.6.0 github.com/shirou/gopsutil/v3 v3.24.5 @@ -81,6 +83,7 @@ require ( github.com/goccy/go-yaml v1.19.2 // indirect github.com/gohugoio/hashstructure v0.6.0 // indirect github.com/gohugoio/hugo v0.163.3 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hairyhenderson/go-codeowners v0.7.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -91,7 +94,6 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/joho/godotenv v1.5.1 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.19.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect @@ -110,6 +112,7 @@ require ( github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.60.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/shoenig/go-m1cpu v0.2.1 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect @@ -122,6 +125,7 @@ require ( github.com/tklauser/numcpus v0.12.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect diff --git a/go.sum b/go.sum index 63d5655..02e737e 100644 --- a/go.sum +++ b/go.sum @@ -141,6 +141,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2/v2 v2.2.2 h1:MYWvNYw8okuqNhwTYO587EZMiDruVa2vhV6fsGpfya0= github.com/dlclark/regexp2/v2 v2.2.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -245,6 +247,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= @@ -307,6 +311,8 @@ github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1r github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc= github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0= github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA= +github.com/mark3labs/mcp-go v0.57.0 h1:jzWKyCzdWnwnZt05cvcQQ+ngiUl2RnixXJa7Kj4qP1E= +github.com/mark3labs/mcp-go v0.57.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= @@ -452,6 +458,8 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/internal/constant/commands.go b/internal/constant/commands.go index 2c9eac5..69ca9f9 100644 --- a/internal/constant/commands.go +++ b/internal/constant/commands.go @@ -32,6 +32,10 @@ var Commands = []CommandInfo{ Name: "task", Description: "系统级任务的列表查询、触发运行、启停控制及状态查看", }, + { + Name: "mcp", + Description: "启动 MCP Server,供 Hermes / OpenClaw 等 AI Agent 接管任务池", + }, { Name: "depinstall", Description: "一键补全指定任务执行日志中的缺失依赖包", diff --git a/main.go b/main.go index 9a47ce1..ce2951e 100644 --- a/main.go +++ b/main.go @@ -52,7 +52,8 @@ func main() { } if handler, ok := cmd.Handlers[commandName]; ok { - if commandName != "-v" && commandName != "-V" && commandName != "version" { + // mcp 是独立 stdio 进程,禁止初始化 DB/日志,避免污染协议流 + if commandName != "-v" && commandName != "-V" && commandName != "version" && commandName != "mcp" { bootstrap.InitBasicForCmd() // 专为命令行工具定制启动基础环境,屏蔽后台启动刷屏日志 } handler(os.Args[2:]) diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 2eca889..6422fba 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -283,6 +283,26 @@ export default function Settings() { 生成 Token +
+
MCP Server(Hermes / OpenClaw)
+

+ OpenAPI 是 REST 接口;AI Agent 请使用 taskpool mcp 作为 MCP + Server 完整接管任务/脚本/日志。 +

+
{`{
+  "mcpServers": {
+    "taskpool": {
+      "command": "taskpool",
+      "args": ["mcp"],
+      "env": {
+        "TASKPOOL_URL": "${window.location.origin}",
+        "TASKPOOL_TOKEN": "${siteForm.openapi_token || '在设置页生成 OpenAPI Token'}"
+      }
+    }
+  }
+}`}
+

详细说明见仓库 docs/guide/mcp.md 与 skills/taskpool/SKILL.md

+

日志保留

setSiteForm((f) => ({ ...f, system_notice_days: e.target.value }))} />