feat(mcp): integrate MCP Server into backend service as built-in endpoint
Build and Deploy / build-and-push (push) Successful in 54s

- 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
This commit is contained in:
2026-07-27 02:38:09 +08:00
parent f004cfc937
commit 066383de83
8 changed files with 870 additions and 31 deletions
+51 -14
View File
@@ -4,34 +4,71 @@ TaskPool 提供官方 **MCP Server**,让 Hermes、OpenClaw、Cursor 等 AI Age
> OpenAPI ≠ MCP > OpenAPI ≠ MCP
> - **OpenAPI**`/open2api/v1` REST 接口(设置页「启用 OpenAPI」) > - **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** 2. 系统设置 → **启用 OpenAPI****生成 Token**
3. 本机或 Agent 环境能访问面板地址(如 `http://127.0.0.1:8052` 3. Agent 能访问面板地址(如 `http://your-server:8052`
## 两种传输模式 ## 内置 MCP 端点
| 模式 | 传输方式 | 适用场景 | Agent 配置 | MCP Server 已**内置在后端服务中**,无需单独启动进程。启用 OpenAPI 后,MCP 端点自动可用:
|------|----------|----------|------------|
| **stdio** | stdin/stdout | 本地 AgentClaude Desktop、Cursor | `command` |
| **http** | HTTP/SSE | 远程 AgentHermes、OpenClaw | `url` |
### 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 ```bash
export TASKPOOL_URL=http://127.0.0.1:8052 export TASKPOOL_URL=http://your-server:8052
export TASKPOOL_TOKEN=你的OpenAPI_Token export TASKPOOL_TOKEN=你的OpenAPI_Token
taskpool mcp 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 ```bash
# 监听默认端口 :8053 # 监听默认端口 :8053
@@ -45,7 +82,7 @@ export MCP_HTTP_ADDR=:8053
taskpool mcp --http taskpool mcp --http
``` ```
HTTP 模式暴露端点: 独立模式暴露端点:
- `POST /mcp` - MCP 协议端点 - `POST /mcp` - MCP 协议端点
- `GET /health` - 健康检查 - `GET /health` - 健康检查
+19
View File
@@ -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)
}
+191
View File
@@ -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] + "..."
}
+569
View File
@@ -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. 不要编造任务 IDID 以 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 MCP1) 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 任务 %s1) 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)
}
+20
View File
@@ -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)
}
}
+1
View File
@@ -72,6 +72,7 @@ func RegisterControllers() *Controllers {
Interconnect: controllers.NewInterconnectController(interconnectService), Interconnect: controllers.NewInterconnectController(interconnectService),
Data: controllers.NewDataController(taskController, envController), Data: controllers.NewDataController(taskController, envController),
Install: controllers.NewInstallController(), Install: controllers.NewInstallController(),
MCP: controllers.NewMCPController(),
} }
} }
+4
View File
@@ -35,6 +35,7 @@ type Controllers struct {
Interconnect *controllers.InterconnectController Interconnect *controllers.InterconnectController
Data *controllers.DataController Data *controllers.DataController
Install *controllers.InstallController Install *controllers.InstallController
MCP *controllers.MCPController
} }
func Setup(c *Controllers) *gin.Engine { func Setup(c *Controllers) *gin.Engine {
@@ -79,6 +80,9 @@ func Setup(c *Controllers) *gin.Engine {
initAgentAPIRoutes(root, c) initAgentAPIRoutes(root, c)
initOpenAPIV1Routes(root, c) initOpenAPIV1Routes(root, c)
// 5. [ location /mcp ] MCP Server 路由(供 AI Agent 使用)
initMCPRoutes(root, c)
// ========================================================================= // =========================================================================
// [ location / ] 全局 404 兜底与 SPA 渲染 // [ location / ] 全局 404 兜底与 SPA 渲染
// 对应 Nginx: try_files $uri $uri/ /index.html; // 对应 Nginx: try_files $uri $uri/ /index.html;
+15 -17
View File
@@ -298,9 +298,23 @@ export default function Settings() {
<div className="rounded-md border border-[var(--border)] bg-[var(--bg-secondary)] p-3 text-xs leading-5 text-[var(--text-secondary)]"> <div className="rounded-md border border-[var(--border)] bg-[var(--bg-secondary)] p-3 text-xs leading-5 text-[var(--text-secondary)]">
<div className="mb-2 font-medium text-[var(--text-primary)]">MCP ServerAI Agent </div> <div className="mb-2 font-medium text-[var(--text-primary)]">MCP ServerAI Agent </div>
<p className="mb-2"> <p className="mb-2">
OpenAPI REST AI Agent 使 MCP Server // MCP Server OpenAPI 使AI Agent MCP //
</p> </p>
<div className="mb-3 space-y-2"> <div className="mb-3 space-y-2">
<div className="rounded border border-[var(--border)] bg-[var(--bg-tertiary)]/50 p-2">
<div className="mb-1 text-xs font-medium text-[var(--text-primary)]">HTTP </div>
<p className="mb-2 text-[var(--text-muted)]"> Agent 使访Hermes / OpenClaw</p>
<pre className="overflow-x-auto whitespace-pre-wrap rounded border border-[var(--border)] bg-[var(--bg-primary)] p-2 font-mono text-[11px] text-[var(--text-primary)]">{`{
"mcpServers": {
"taskpool": {
"url": "${window.location.origin}/mcp",
"headers": {
"Authorization": "Bearer ${siteForm.openapi_token || '你的Token'}"
}
}
}
}`}</pre>
</div>
<div className="rounded border border-[var(--border)] bg-[var(--bg-tertiary)]/50 p-2"> <div className="rounded border border-[var(--border)] bg-[var(--bg-tertiary)]/50 p-2">
<div className="mb-1 text-xs font-medium text-[var(--text-primary)]">stdio </div> <div className="mb-1 text-xs font-medium text-[var(--text-primary)]">stdio </div>
<p className="mb-2 text-[var(--text-muted)]"> Agent 使 taskpool Claude Desktop / Cursor</p> <p className="mb-2 text-[var(--text-muted)]"> Agent 使 taskpool Claude Desktop / Cursor</p>
@@ -315,22 +329,6 @@ export default function Settings() {
} }
} }
} }
}`}</pre>
</div>
<div className="rounded border border-[var(--border)] bg-[var(--bg-tertiary)]/50 p-2">
<div className="mb-1 text-xs font-medium text-[var(--text-primary)]">HTTP </div>
<p className="mb-2 text-[var(--text-muted)]"> Agent 使 URL 访Hermes / OpenClaw</p>
<div className="mb-2 text-[var(--text-muted)]">1. MCP HTTP </div>
<pre className="mb-2 overflow-x-auto whitespace-pre-wrap rounded border border-[var(--border)] bg-[var(--bg-primary)] p-2 font-mono text-[11px] text-[var(--text-primary)]">{`TASKPOOL_URL=${window.location.origin} \\
TASKPOOL_TOKEN=${siteForm.openapi_token || '你的Token'} \\
taskpool mcp --http :8053`}</pre>
<div className="mb-2 text-[var(--text-muted)]">2. Agent </div>
<pre className="overflow-x-auto whitespace-pre-wrap rounded border border-[var(--border)] bg-[var(--bg-primary)] p-2 font-mono text-[11px] text-[var(--text-primary)]">{`{
"mcpServers": {
"taskpool": {
"url": "http://你的服务器:8053/mcp"
}
}
}`}</pre> }`}</pre>
</div> </div>
</div> </div>