From 066383de83bf97515eb5a2caff891197bc697bb8 Mon Sep 17 00:00:00 2001 From: TaskPool Date: Mon, 27 Jul 2026 02:38:09 +0800 Subject: [PATCH] feat(mcp): integrate MCP Server into backend service as built-in endpoint - Add MCP Controller and register /mcp route - MCP Server now uses OpenAPI Token for authentication - Update frontend settings to show built-in MCP endpoint config - Update docs to reflect integrated MCP endpoint --- docs/guide/mcp.md | 65 ++- internal/controllers/mcp_controller.go | 19 + internal/mcp/client.go | 191 +++++++++ internal/mcp/server.go | 569 +++++++++++++++++++++++++ internal/router/mcp.go | 20 + internal/router/register.go | 1 + internal/router/router.go | 4 + web/src/pages/Settings.tsx | 32 +- 8 files changed, 870 insertions(+), 31 deletions(-) create mode 100644 internal/controllers/mcp_controller.go create mode 100644 internal/mcp/client.go create mode 100644 internal/mcp/server.go create mode 100644 internal/router/mcp.go diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index 6703c27..42bd1b8 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -4,34 +4,71 @@ TaskPool 提供官方 **MCP Server**,让 Hermes、OpenClaw、Cursor 等 AI Age > OpenAPI ≠ MCP > - **OpenAPI**:`/open2api/v1` REST 接口(设置页「启用 OpenAPI」) -> - **MCP Server**:`taskpool mcp`,把上述接口封装成 Agent 可调用的 Tools +> - **MCP Server**:`/mcp` HTTP 端点,把 OpenAPI 封装成 Agent 可调用的 Tools ## 前置条件 -1. 启动后端:`taskpool server` +1. 启动后端服务(Docker 或 `taskpool server`) 2. 系统设置 → **启用 OpenAPI** → **生成 Token** -3. 本机或 Agent 环境能访问面板地址(如 `http://127.0.0.1:8052`) +3. Agent 能访问面板地址(如 `http://your-server:8052`) -## 两种传输模式 +## 内置 MCP 端点 -| 模式 | 传输方式 | 适用场景 | Agent 配置 | -|------|----------|----------|------------| -| **stdio** | stdin/stdout | 本地 Agent(Claude Desktop、Cursor) | `command` | -| **http** | HTTP/SSE | 远程 Agent(Hermes、OpenClaw) | `url` | +MCP Server 已**内置在后端服务中**,无需单独启动进程。启用 OpenAPI 后,MCP 端点自动可用: -### stdio 模式(默认) +``` +http://your-server:8052/mcp +``` -由 Agent 拉起子进程,通过标准输入输出通信。日志输出在 **stderr**,不会污染协议。 +## Agent 配置示例 + +### HTTP 模式(推荐) + +远程 Agent 直接访问内置端点,无需本地安装二进制。 + +```json +{ + "mcpServers": { + "taskpool": { + "url": "http://your-server:8052/mcp", + "headers": { + "Authorization": "Bearer 你的OpenAPI_Token" + } + } + } +} +``` + +### stdio 模式 + +本地 Agent 需要安装 `taskpool` 二进制(Claude Desktop、Cursor)。 ```bash -export TASKPOOL_URL=http://127.0.0.1:8052 +export TASKPOOL_URL=http://your-server:8052 export TASKPOOL_TOKEN=你的OpenAPI_Token taskpool mcp ``` -### HTTP 模式 +Agent 配置: -独立 HTTP 服务,Agent 通过 URL 访问,无需本地安装二进制。 +```json +{ + "mcpServers": { + "taskpool": { + "command": "taskpool", + "args": ["mcp"], + "env": { + "TASKPOOL_URL": "http://your-server:8052", + "TASKPOOL_TOKEN": "你的OpenAPI_Token" + } + } + } +} +``` + +### 独立 HTTP 服务模式(可选) + +如果需要单独部署 MCP 服务(如不同的端口或机器): ```bash # 监听默认端口 :8053 @@ -45,7 +82,7 @@ export MCP_HTTP_ADDR=:8053 taskpool mcp --http ``` -HTTP 模式暴露端点: +独立模式暴露端点: - `POST /mcp` - MCP 协议端点 - `GET /health` - 健康检查 diff --git a/internal/controllers/mcp_controller.go b/internal/controllers/mcp_controller.go new file mode 100644 index 0000000..4b83f57 --- /dev/null +++ b/internal/controllers/mcp_controller.go @@ -0,0 +1,19 @@ +package controllers + +import ( + "github.com/engigu/taskpool/internal/mcp" + "github.com/gin-gonic/gin" +) + +type MCPController struct{} + +func NewMCPController() *MCPController { + return &MCPController{} +} + +// HandleMCP 处理 MCP 请求(StreamableHTTP) +func (c *MCPController) HandleMCP(ctx *gin.Context) { + // 获取 MCP HTTP Handler 并处理请求 + handler := mcp.GetHTTPHandler() + handler.ServeHTTP(ctx.Writer, ctx.Request) +} diff --git a/internal/mcp/client.go b/internal/mcp/client.go new file mode 100644 index 0000000..c03f63c --- /dev/null +++ b/internal/mcp/client.go @@ -0,0 +1,191 @@ +package mcp + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/engigu/taskpool/internal/constant" + "github.com/engigu/taskpool/internal/models/vo" + "github.com/engigu/taskpool/internal/services" +) + +var ( + openAPIClient *Client + openAPIClientOnce sync.Once +) + +// GetOpenAPIClient 获取内部 OpenAPI 客户端(单例) +// 使用当前服务器的地址和系统设置中的 OpenAPI Token +func GetOpenAPIClient() *Client { + openAPIClientOnce.Do(func() { + cfg := services.GetConfig() + baseURL := fmt.Sprintf("http://127.0.0.1:%d", cfg.Server.Port) + if cfg.Server.URLPrefix != "" { + baseURL += cfg.Server.URLPrefix + } + + settingsSvc := services.NewSettingsService() + token := getOpenAPIToken(settingsSvc) + + openAPIClient = NewClient(baseURL, token) + }) + return openAPIClient +} + +// RefreshClient 刷新客户端(当 OpenAPI Token 变更时调用) +func RefreshClient() { + settingsSvc := services.NewSettingsService() + token := getOpenAPIToken(settingsSvc) + if openAPIClient != nil { + openAPIClient.Token = token + } +} + +func getOpenAPIToken(settingsSvc *services.SettingsService) string { + siteConfig := settingsSvc.GetSection(constant.SectionSite) + tokenJson, ok := siteConfig[constant.KeyOpenapiToken] + if !ok || tokenJson == "" { + return "" + } + + var tokenConfig vo.TokenConfig + if err := json.Unmarshal([]byte(tokenJson), &tokenConfig); err != nil { + return "" + } + + if !tokenConfig.Enabled { + return "" + } + + return tokenConfig.Token +} + +// Client 调用内部 OpenAPI +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("服务器地址未配置") + } + if c.Token == "" { + return nil, fmt.Errorf("OpenAPI Token 未配置,请在系统设置中启用 OpenAPI 并生成 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 + } + + 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 + } + 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 truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go new file mode 100644 index 0000000..52ac1b4 --- /dev/null +++ b/internal/mcp/server.go @@ -0,0 +1,569 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "sync" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const ( + serverName = "taskpool" + serverVersion = "1.0.0" +) + +var ( + mcpServer *server.MCPServer + mcpHTTPServer *server.StreamableHTTPServer + once sync.Once +) + +// GetServer 获取 MCP Server 实例(单例) +func GetServer() *server.MCPServer { + once.Do(func() { + mcpServer = server.NewMCPServer( + serverName, + serverVersion, + server.WithToolCapabilities(true), + server.WithResourceCapabilities(false, false), + server.WithPromptCapabilities(true), + server.WithInstructions(instructions), + server.WithRecovery(), + ) + registerTools(mcpServer) + registerPrompts(mcpServer) + }) + return mcpServer +} + +// GetHTTPHandler 获取 MCP HTTP Handler(用于集成到 Gin) +func GetHTTPHandler() http.Handler { + s := GetServer() + if mcpHTTPServer == nil { + mcpHTTPServer = server.NewStreamableHTTPServer(s, + server.WithEndpointPath(""), + ) + } + return mcpHTTPServer +} + +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) { + // ---- 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 := GetOpenAPIClient().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 := GetOpenAPIClient().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 := GetOpenAPIClient().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 := GetOpenAPIClient().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 := GetOpenAPIClient().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 := GetOpenAPIClient().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 := GetOpenAPIClient().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 := GetOpenAPIClient().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 := GetOpenAPIClient().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 := GetOpenAPIClient().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 := GetOpenAPIClient().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 := GetOpenAPIClient().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 := GetOpenAPIClient().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 := GetOpenAPIClient().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 := GetOpenAPIClient().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 := GetOpenAPIClient().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 := GetOpenAPIClient().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 := GetOpenAPIClient().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 := GetOpenAPIClient().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 := GetOpenAPIClient().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 := GetOpenAPIClient().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 := GetOpenAPIClient().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 := GetOpenAPIClient().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 { + 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 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) +} diff --git a/internal/router/mcp.go b/internal/router/mcp.go new file mode 100644 index 0000000..4f64545 --- /dev/null +++ b/internal/router/mcp.go @@ -0,0 +1,20 @@ +package router + +import ( + "github.com/engigu/taskpool/internal/middleware" + "github.com/gin-gonic/gin" +) + +// initMCPRoutes 初始化 MCP Server 路由 +// 使用 OpenAPI Token 进行鉴权 +func initMCPRoutes(root *gin.RouterGroup, c *Controllers) { + // MCP 路由组 (使用 Bearer Token 鉴权,复用 OpenAPI 中间件) + mcp := root.Group("/mcp") + mcp.Use(middleware.OpenapiRequired()) + { + // 所有 MCP 请求都通过同一个 handler 处理 + // StreamableHTTP 协议支持 POST 和 GET + mcp.POST("/*action", c.MCP.HandleMCP) + mcp.GET("/*action", c.MCP.HandleMCP) + } +} diff --git a/internal/router/register.go b/internal/router/register.go index 411f64b..d414571 100644 --- a/internal/router/register.go +++ b/internal/router/register.go @@ -72,6 +72,7 @@ func RegisterControllers() *Controllers { Interconnect: controllers.NewInterconnectController(interconnectService), Data: controllers.NewDataController(taskController, envController), Install: controllers.NewInstallController(), + MCP: controllers.NewMCPController(), } } diff --git a/internal/router/router.go b/internal/router/router.go index 623b30f..8789a2d 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -35,6 +35,7 @@ type Controllers struct { Interconnect *controllers.InterconnectController Data *controllers.DataController Install *controllers.InstallController + MCP *controllers.MCPController } func Setup(c *Controllers) *gin.Engine { @@ -79,6 +80,9 @@ func Setup(c *Controllers) *gin.Engine { initAgentAPIRoutes(root, c) initOpenAPIV1Routes(root, c) + // 5. [ location /mcp ] MCP Server 路由(供 AI Agent 使用) + initMCPRoutes(root, c) + // ========================================================================= // [ location / ] 全局 404 兜底与 SPA 渲染 // 对应 Nginx: try_files $uri $uri/ /index.html; diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 7796fc4..002fc3d 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -298,9 +298,23 @@ export default function Settings() {
MCP Server(AI Agent 接入)

- OpenAPI 是 REST 接口;AI Agent 请使用 MCP Server 完整接管任务/脚本/日志。支持两种模式: + MCP Server 已内置在后端服务中,启用 OpenAPI 后即可使用。AI Agent 可通过 MCP 协议完整接管任务/脚本/日志。

+
+
HTTP 模式(推荐)
+

远程 Agent 使用,直接访问内置端点(Hermes / OpenClaw)

+
{`{
+  "mcpServers": {
+    "taskpool": {
+      "url": "${window.location.origin}/mcp",
+      "headers": {
+        "Authorization": "Bearer ${siteForm.openapi_token || '你的Token'}"
+      }
+    }
+  }
+}`}
+
stdio 模式

本地 Agent 使用,需要安装 taskpool 二进制(Claude Desktop / Cursor)

@@ -315,22 +329,6 @@ export default function Settings() { } } } -}`} -
-
-
HTTP 模式
-

远程 Agent 使用,通过 URL 访问(Hermes / OpenClaw)

-
1. 启动 MCP HTTP 服务:
-
{`TASKPOOL_URL=${window.location.origin} \\
-TASKPOOL_TOKEN=${siteForm.openapi_token || '你的Token'} \\
-taskpool mcp --http :8053`}
-
2. Agent 配置:
-
{`{
-  "mcpServers": {
-    "taskpool": {
-      "url": "http://你的服务器:8053/mcp"
-    }
-  }
 }`}