Compare commits

...

11 Commits

Author SHA1 Message Date
admin 060a8f132d revert: remove SetTrustedProxies modification
Build and Deploy / build-and-push (push) Successful in 52s
2026-07-27 03:23:51 +08:00
admin 19b3fb60f7 fix(router): trust all proxies to fix MCP 'invalid Host header' error
Build and Deploy / build-and-push (push) Successful in 53s
- Set TrustedProxies to nil to allow reverse proxy requests
- Add explicit routes for /mcp without trailing slash
2026-07-27 03:10:24 +08:00
admin 2c9b44aeca fix(nginx): add proxy config for /mcp and /open2api endpoints
Build and Deploy / build-and-push (push) Successful in 53s
2026-07-27 03:04:20 +08:00
admin bcf7cbbeea refactor(mcp): remove standalone HTTP mode, keep only built-in endpoint and stdio
Build and Deploy / build-and-push (push) Successful in 54s
- MCP Server is now only available via built-in /mcp endpoint or stdio
- Remove --http flag and standalone HTTP server code
- Simplify documentation and frontend settings
2026-07-27 02:52:46 +08:00
admin f3a44a901e refactor(mcp): consolidate MCP implementation into internal package
Build and Deploy / build-and-push (push) Successful in 54s
- Move MCP tools registration to internal/mcp
- cmd/mcp now only contains CLI entry logic
- Support env vars TASKPOOL_URL/TASKPOOL_TOKEN for stdio mode
- Remove duplicate client.go from cmd/mcp
2026-07-27 02:43:01 +08:00
admin 066383de83 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
2026-07-27 02:38:09 +08:00
admin f004cfc937 style(ui): unify settings input fields with consistent label style
Build and Deploy / build-and-push (push) Successful in 52s
2026-07-27 02:27:12 +08:00
admin 334152417b feat(ui): show both stdio and HTTP mode MCP config in settings
Build and Deploy / build-and-push (push) Successful in 54s
2026-07-27 02:25:48 +08:00
admin 6709c4ed77 feat(ui): improve settings form labels and descriptions for clarity
Build and Deploy / build-and-push (push) Successful in 53s
2026-07-27 02:22:35 +08:00
admin d104442e6b feat(mcp): add HTTP mode for remote Agent access (Hermes/OpenClaw URL support)
Build and Deploy / build-and-push (push) Successful in 52s
2026-07-27 02:20:30 +08:00
admin a9024ff6f8 feat(ui): add size system (sm/md/lg) + compact mode + remove collapsed dot
Build and Deploy / build-and-push (push) Successful in 53s
2026-07-26 20:14:42 +08:00
21 changed files with 1062 additions and 683 deletions
+60 -573
View File
@@ -1,26 +1,18 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/engigu/taskpool/internal/mcp"
imc "github.com/engigu/taskpool/internal/mcp"
)
const (
serverName = "taskpool"
serverVersion = "1.0.0"
)
// Run 启动 TaskPool MCP Serverstdio
// 环境变量:
// TASKPOOL_URL 面板地址,如 http://127.0.0.1:8052 或 https://panel.example.com
// TASKPOOL_TOKEN 设置页 OpenAPI Token
// Run 启动 TaskPool MCP Serverstdio 模式)
// 环境变量(可选,默认使用本地服务器配置):
// TASKPOOL_URL 面板地址
// TASKPOOL_TOKEN OpenAPI Token
func Run(args []string) {
for _, a := range args {
if a == "-h" || a == "--help" {
@@ -29,571 +21,22 @@ func Run(args []string) {
}
}
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"))
// 使用内部 OpenAPI 客户端(自动获取服务器地址和 Token)
client := imc.GetOpenAPIClient()
fmt.Fprintf(os.Stderr, "[taskpool-mcp] stdio mode, url=%s token=%s\n", client.BaseURL, maskToken(client.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(),
)
s := mcp.GetServer()
// 设置外部客户端(用于连接远程服务器)
if client.Token != "" {
mcp.SetExternalClient(client)
}
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)
if err := mcp.ServeStdio(s); err != nil {
fmt.Fprintf(os.Stderr, "[taskpool-mcp] 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. 不要编造任务 IDID 以 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 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 {
// 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 == "" {
@@ -604,3 +47,47 @@ func maskToken(t string) string {
}
return t[:4] + "****" + t[len(t)-4:]
}
func printHelp() {
fmt.Fprintf(os.Stderr, `
TaskPool MCP Server
MCP Server 已内置在后端服务中:
HTTP 端点: http://your-server:8052/mcp
此命令仅用于 stdio 模式(Claude Desktop / Cursor 等)。
用法:
taskpool mcp
环境变量(可选):
TASKPOOL_URL 面板地址(默认使用本机)
TASKPOOL_TOKEN OpenAPI Token(默认从系统设置读取)
Agent 配置:
HTTP 模式(Hermes / OpenClaw:
{
"mcpServers": {
"taskpool": {
"url": "http://your-server:8052/mcp",
"headers": {
"Authorization": "Bearer 你的Token"
}
}
}
}
stdio 模式(Claude Desktop / Cursor:
{
"mcpServers": {
"taskpool": {
"command": "taskpool",
"args": ["mcp"]
}
}
}
前置条件:
系统设置 → 启用 OpenAPI → 生成 Token
`)
}
+30 -26
View File
@@ -4,55 +4,59 @@ 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 端点
```bash
export TASKPOOL_URL=http://127.0.0.1:8052
export TASKPOOL_TOKEN=你的OpenAPI_Token
taskpool mcp
MCP Server 已**内置在后端服务中**,无需单独进程。启用 OpenAPI 后,MCP 端点自动可用:
```
http://your-server:8052/mcp
```
仅 stdio 传输:由 Agent 拉起子进程,通过标准输入输出通信。日志输出在 **stderr**,不会污染协议。
## Agent 配置
## Hermes / OpenClaw 配置
### HTTP 模式(推荐)
远程 Agent 直接访问内置端点,无需本地安装。
```json
{
"mcpServers": {
"taskpool": {
"command": "taskpool",
"args": ["mcp"],
"env": {
"TASKPOOL_URL": "http://127.0.0.1:8052",
"TASKPOOL_TOKEN": "替换为设置页 Token"
"url": "http://your-server:8052/mcp",
"headers": {
"Authorization": "Bearer 你的OpenAPI_Token"
}
}
}
}
```
若二进制不在 PATH
适用于:Hermes、OpenClaw 等支持 HTTP URL 的 Agent。
### stdio 模式
本地 Agent 需要安装 `taskpool` 二进制。
```json
{
"command": "/path/to/taskpool",
"args": ["mcp"],
"env": {
"TASKPOOL_URL": "https://你的面板地址",
"TASKPOOL_TOKEN": "xxx"
"mcpServers": {
"taskpool": {
"command": "taskpool",
"args": ["mcp"]
}
}
}
```
Docker 部署时,把 `TASKPOOL_URL` 写成 Agent 能访问的地址(宿主机映射端口或内网域名)
适用于:Claude Desktop、Cursor 等只支持 stdio 的本地 Agent
## 工具清单(Tools
@@ -119,7 +123,7 @@ Skill 是说明书;**真正执行依赖 MCP Tools**。两者一起用效果最
| 现象 | 处理 |
|------|------|
| `TASKPOOL_TOKEN 未配置` | 设置 env 或配置里的 Token |
| `无效的 OpenAPI 令牌` | 设置页启用 OpenAPI 并重新生成 |
| 连接失败 | 检查 `TASKPOOL_URL`、防火墙、容器网络 |
| Agent 看不到 tools | 确认 `command` 路径正确,重启 Agent Gateway |
| `OpenAPI Token 未配置` | 设置页启用 OpenAPI 并生成 Token |
| `无效的 OpenAPI 令牌` | 设置页重新生成 Token |
| 连接失败 | 检查服务器地址、防火墙、容器网络 |
| Agent 看不到 tools | 确认配置正确,Token 有效 |
+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)
}
+78 -25
View File
@@ -7,11 +7,86 @@ import (
"io"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/engigu/taskpool/internal/constant"
"github.com/engigu/taskpool/internal/models/vo"
"github.com/engigu/taskpool/internal/services"
)
// Client 调用 TaskPool OpenAPI (/open2api/v1)
var (
openAPIClient *Client
openAPIClientOnce sync.Once
)
// GetOpenAPIClient 获取 OpenAPI 客户端(单例)
// 优先使用环境变量 TASKPOOL_URL 和 TASKPOOL_TOKEN
// 否则使用本地服务器地址和系统设置中的 OpenAPI Token
func GetOpenAPIClient() *Client {
openAPIClientOnce.Do(func() {
// 优先从环境变量读取(支持 stdio 模式连接远程服务器)
envURL := strings.TrimSpace(os.Getenv("TASKPOOL_URL"))
envToken := strings.TrimSpace(os.Getenv("TASKPOOL_TOKEN"))
if envURL == "" {
envURL = strings.TrimSpace(os.Getenv("BAIHU_URL"))
}
if envToken == "" {
envToken = strings.TrimSpace(os.Getenv("BAIHU_TOKEN"))
}
var baseURL, token string
if envURL != "" && envToken != "" {
// 使用环境变量配置
baseURL = envURL
token = envToken
} else {
// 使用本地服务器配置
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
@@ -34,10 +109,10 @@ func (c *Client) openBase() string {
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 未配置")
return nil, fmt.Errorf("服务器地址未配置")
}
if c.Token == "" {
return nil, fmt.Errorf("TASKPOOL_TOKEN 未配置")
return nil, fmt.Errorf("OpenAPI Token 未配置,请在系统设置中启用 OpenAPI 并生成 Token")
}
u, err := url.Parse(c.openBase() + path)
@@ -96,8 +171,6 @@ func (c *Client) do(method, path string, query map[string]string, body any) (jso
return raw, nil
}
// TaskPool 统一返回 HTTP 200,用 JSON body 中 code 区分成功/失败
// code=200 → 成功;其他 → 错误
if envelope.Code != 200 {
msg := envelope.Msg
if msg == "" {
@@ -108,7 +181,6 @@ func (c *Client) do(method, path string, query map[string]string, body any) (jso
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
@@ -130,25 +202,6 @@ 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
+588
View File
@@ -0,0 +1,588 @@
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
externalClient *Client // 外部客户端(用于 stdio 模式连接远程服务器)
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
}
// SetExternalClient 设置外部 OpenAPI 客户端(用于 stdio 模式)
func SetExternalClient(c *Client) {
externalClient = c
}
// getClient 获取当前使用的客户端
func getClient() *Client {
if externalClient != nil {
return externalClient
}
return GetOpenAPIClient()
}
// GetHTTPHandler 获取 MCP HTTP Handler(用于集成到 Gin
func GetHTTPHandler() http.Handler {
s := GetServer()
if mcpHTTPServer == nil {
mcpHTTPServer = server.NewStreamableHTTPServer(s,
server.WithEndpointPath(""),
)
}
return mcpHTTPServer
}
// ServeStdio 启动 stdio 模式的 MCP Server
func ServeStdio(s *server.MCPServer) error {
return server.ServeStdio(s)
}
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)
}
+29
View File
@@ -0,0 +1,29 @@
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
// 使用 /*action 匹配 /mcp 和 /mcp/ 以及 /mcp/xxx
mcp.POST("/*action", c.MCP.HandleMCP)
mcp.GET("/*action", c.MCP.HandleMCP)
}
// 同时处理 /mcp 根路径(无尾部斜杠)
mcpRoot := root.Group("/mcp")
mcpRoot.Use(middleware.OpenapiRequired())
{
mcpRoot.POST("", c.MCP.HandleMCP)
mcpRoot.GET("", c.MCP.HandleMCP)
}
}
+1
View File
@@ -72,6 +72,7 @@ func RegisterControllers() *Controllers {
Interconnect: controllers.NewInterconnectController(interconnectService),
Data: controllers.NewDataController(taskController, envController),
Install: controllers.NewInstallController(),
MCP: controllers.NewMCPController(),
}
}
+4
View File
@@ -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;
+26 -5
View File
@@ -9,11 +9,6 @@ server {
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
gzip_min_length 1000;
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# API proxy - 指向同一容器内的后端
location /api/ {
proxy_pass http://127.0.0.1:8052;
@@ -24,6 +19,32 @@ server {
proxy_set_header X-Real-IP $remote_addr;
}
# MCP Server proxy - AI Agent 接入
location /mcp {
proxy_pass http://127.0.0.1:8052;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 支持 SSE 流式响应
proxy_buffering off;
proxy_cache off;
}
# OpenAPI proxy
location /open2api/ {
proxy_pass http://127.0.0.1:8052;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
-3
View File
@@ -211,9 +211,6 @@ export default function Layout() {
{active ? <span className="ml-auto h-1.5 w-1.5 rounded-full bg-[var(--bg-accent)]" /> : null}
</>
)}
{sidebarCollapsed && active ? (
<span className="absolute right-1 top-1/2 h-1.5 w-1.5 -translate-y-1/2 rounded-full bg-[var(--bg-accent)]" />
) : null}
</button>
)
})}
+8 -1
View File
@@ -6,9 +6,11 @@ export type BadgeTone = 'default' | 'success' | 'warn' | 'danger' | 'info'
export function Badge({
children,
tone = 'default',
size = 'md',
}: {
children: ReactNode
tone?: BadgeTone
size?: 'sm' | 'md' | 'lg'
}) {
const tones: Record<BadgeTone, string> = {
default: 'border-[var(--border)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)]',
@@ -21,8 +23,13 @@ export function Badge({
info:
'border-[color-mix(in_srgb,var(--info)_25%,transparent)] bg-[color-mix(in_srgb,var(--info)_12%,transparent)] text-[var(--info)]',
}
const badgeSizes = {
sm: 'px-1.5 py-px text-[10px]',
md: 'px-2 py-0.5 text-[11px]',
lg: 'px-2.5 py-1 text-[12px]',
}
return (
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-[11px] font-medium', tones[tone])}>
<span className={cn('inline-flex items-center rounded-md border font-medium', badgeSizes[size], tones[tone])}>
{children}
</span>
)
+1 -1
View File
@@ -2,7 +2,7 @@ import type { ButtonHTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
export type ButtonVariant = 'default' | 'secondary' | 'ghost' | 'danger' | 'outline'
export type ButtonSize = 'sm' | 'md' | 'icon'
export type ButtonSize = 'sm' | 'md' | 'lg' | 'icon' | 'icon-sm' | 'icon-lg'
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant
+1
View File
@@ -10,6 +10,7 @@ export { Badge } from './badge'
export type { BadgeTone } from './badge'
export { Modal } from './modal'
export { Table, Th, Td } from './table'
export type { TableSize } from './table'
export { Field } from './field'
export { PageHeader } from './page-header'
export { Empty } from './empty'
+8 -2
View File
@@ -1,6 +1,12 @@
import type { InputHTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
export function Input({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) {
return <input {...props} className={cn('ui-control h-9 px-3', className)} />
const heights: Record<string, string> = {
sm: 'h-[var(--ui-height-sm)] text-[length:var(--ui-font-sm)] px-[var(--ui-px-sm)]',
md: 'h-[var(--ui-height-md)] text-[length:var(--ui-font-md)] px-[var(--ui-px-md)]',
lg: 'h-[var(--ui-height-lg)] text-[length:var(--ui-font-lg)] px-[var(--ui-px-lg)]',
}
export function Input({ className, size = 'md', ...props }: InputHTMLAttributes<HTMLInputElement> & { size?: 'sm' | 'md' | 'lg' }) {
return <input {...props} className={cn('ui-control', heights[size], className)} />
}
+1 -1
View File
@@ -144,7 +144,7 @@ export function Select({
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
className={cn('ui-control ui-select-trigger h-9 px-3 text-left', open && 'ui-control-open')}
className={cn('ui-control ui-select-trigger h-[var(--ui-height-md)] px-[var(--ui-px-md)] text-left text-[length:var(--ui-font-md)]', open && 'ui-control-open')}
onClick={() => {
if (disabled) return
setOpen((v) => !v)
+14 -3
View File
@@ -1,16 +1,25 @@
import { cn } from '@/lib/utils'
const sizes = {
sm: { track: 'h-4 w-7', thumb: 'h-2.5 w-2.5', on: 'translate-x-[14px]', off: 'translate-x-[2px]' },
md: { track: 'h-5 w-9', thumb: 'h-3.5 w-3.5', on: 'translate-x-[18px]', off: 'translate-x-[3px]' },
lg: { track: 'h-6 w-11', thumb: 'h-4 w-4', on: 'translate-x-[22px]', off: 'translate-x-[3px]' },
}
export function Switch({
checked,
onCheckedChange,
disabled,
className,
size = 'md',
}: {
checked: boolean
onCheckedChange: (v: boolean) => void
disabled?: boolean
className?: string
size?: 'sm' | 'md' | 'lg'
}) {
const s = sizes[size]
return (
<button
type="button"
@@ -18,7 +27,8 @@ export function Switch({
aria-checked={checked}
disabled={disabled}
className={cn(
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full border transition',
'relative inline-flex shrink-0 items-center rounded-full border transition',
s.track,
checked ? 'border-transparent bg-[var(--bg-accent)]' : 'border-[var(--border)] bg-[var(--bg-tertiary)]',
disabled && 'opacity-55 cursor-not-allowed',
className,
@@ -27,8 +37,9 @@ export function Switch({
>
<span
className={cn(
'inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform',
checked ? 'translate-x-[18px]' : 'translate-x-[3px]',
'inline-block rounded-full bg-white shadow transition-transform',
s.thumb,
checked ? s.on : s.off,
)}
/>
</button>
+19 -4
View File
@@ -1,6 +1,20 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
export type TableSize = 'sm' | 'md' | 'lg'
const thSizes: Record<TableSize, string> = {
sm: 'px-2 py-[var(--ui-table-py-sm)] text-[10px]',
md: 'px-3 py-[var(--ui-table-py-md)] text-[11px]',
lg: 'px-4 py-[var(--ui-table-py-lg)] text-[12px]',
}
const tdSizes: Record<TableSize, string> = {
sm: 'px-2 py-[var(--ui-table-py-sm)] text-[length:var(--ui-font-sm)]',
md: 'px-3 py-[var(--ui-table-py-md)] text-[length:var(--ui-font-md)]',
lg: 'px-4 py-[var(--ui-table-py-lg)] text-[length:var(--ui-font-lg)]',
}
export function Table({ children }: { children: ReactNode }) {
return (
<div className="overflow-x-auto">
@@ -9,11 +23,12 @@ export function Table({ children }: { children: ReactNode }) {
)
}
export function Th({ children, className }: { children?: ReactNode; className?: string }) {
export function Th({ children, className, size = 'md' }: { children?: ReactNode; className?: string; size?: TableSize }) {
return (
<th
className={cn(
'border-b border-[var(--border)] bg-[var(--bg-secondary)] px-3 py-2.5 text-[11px] font-medium uppercase tracking-wide text-[var(--text-muted)]',
'border-b border-[var(--border)] bg-[var(--bg-secondary)] font-medium uppercase tracking-wide text-[var(--text-muted)]',
thSizes[size],
className,
)}
>
@@ -22,9 +37,9 @@ export function Th({ children, className }: { children?: ReactNode; className?:
)
}
export function Td({ children, className }: { children?: ReactNode; className?: string }) {
export function Td({ children, className, size = 'md' }: { children?: ReactNode; className?: string; size?: TableSize }) {
return (
<td className={cn('border-b border-[var(--border)] px-3 py-2.5 align-middle text-[var(--text-primary)]', className)}>
<td className={cn('border-b border-[var(--border)] align-middle text-[var(--text-primary)]', tdSizes[size], className)}>
{children}
</td>
)
+9 -1
View File
@@ -5,12 +5,19 @@ export function Tabs({
onValueChange,
items,
className,
size = 'md',
}: {
value: string
onValueChange: (v: string) => void
items: { value: string; label: string }[]
className?: string
size?: 'sm' | 'md' | 'lg'
}) {
const tabSizes = {
sm: 'px-2.5 py-1 text-[12px]',
md: 'px-3.5 py-1.5 text-[13px]',
lg: 'px-4 py-2 text-[14px]',
}
return (
<div
className={cn(
@@ -29,7 +36,8 @@ export function Tabs({
role="tab"
aria-selected={active}
className={cn(
'relative shrink-0 rounded-md px-3.5 py-1.5 text-[13px] font-medium whitespace-nowrap transition-colors',
'relative shrink-0 rounded-md font-medium whitespace-nowrap transition-colors',
tabSizes[size],
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--focus-ring)]',
active
? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-[var(--shadow-sm)] ring-1 ring-[var(--border)]'
+8 -2
View File
@@ -1,6 +1,12 @@
import type { TextareaHTMLAttributes } from 'react'
import { cn } from '@/lib/utils'
export function Textarea({ className, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
return <textarea {...props} className={cn('ui-control min-h-24 resize-y px-3 py-2', className)} />
const sizes: Record<string, string> = {
sm: 'text-[length:var(--ui-font-sm)] px-[var(--ui-px-sm)] py-1.5 min-h-16',
md: 'text-[length:var(--ui-font-md)] px-[var(--ui-px-md)] py-2 min-h-24',
lg: 'text-[length:var(--ui-font-lg)] px-[var(--ui-px-lg)] py-2.5 min-h-32',
}
export function Textarea({ className, size = 'md', ...props }: TextareaHTMLAttributes<HTMLTextAreaElement> & { size?: 'sm' | 'md' | 'lg' }) {
return <textarea {...props} className={cn('ui-control resize-y', sizes[size], className)} />
}
+68 -11
View File
@@ -78,6 +78,44 @@
--info: #2563eb;
}
/* ---- 尺寸系统:默认 md ---- */
:root {
--ui-height-sm: 1.75rem; /* 28px */
--ui-height-md: 2.25rem; /* 36px */
--ui-height-lg: 2.75rem; /* 44px */
--ui-font-sm: 12px;
--ui-font-md: 14px;
--ui-font-lg: 15px;
--ui-px-sm: 0.5rem;
--ui-px-md: 0.75rem;
--ui-px-lg: 1rem;
--ui-gap-sm: 0.25rem;
--ui-gap-md: 0.375rem;
--ui-gap-lg: 0.5rem;
--ui-table-py-sm: 0.375rem;
--ui-table-py-md: 0.625rem;
--ui-table-py-lg: 0.75rem;
}
/* 紧凑模式 */
[data-density='compact'] {
--ui-height-sm: 1.5rem; /* 24px */
--ui-height-md: 1.875rem; /* 30px */
--ui-height-lg: 2.25rem; /* 36px */
--ui-font-sm: 11px;
--ui-font-md: 13px;
--ui-font-lg: 14px;
--ui-px-sm: 0.375rem;
--ui-px-md: 0.5rem;
--ui-px-lg: 0.75rem;
--ui-gap-sm: 0.125rem;
--ui-gap-md: 0.25rem;
--ui-gap-lg: 0.375rem;
--ui-table-py-sm: 0.25rem;
--ui-table-py-md: 0.375rem;
--ui-table-py-lg: 0.5rem;
}
*,
*::before,
*::after {
@@ -234,10 +272,10 @@ pre,
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
gap: var(--ui-gap-md);
border: 1px solid transparent;
border-radius: var(--radius-md);
font-size: 14px;
font-size: var(--ui-font-md);
font-weight: 500;
line-height: 1.2;
transition:
@@ -310,19 +348,38 @@ pre,
}
.ui-btn-sm {
height: 2rem;
padding: 0 0.625rem;
font-size: 12px;
height: var(--ui-height-sm);
padding: 0 var(--ui-px-sm);
font-size: var(--ui-font-sm);
}
.ui-btn-md {
height: 2.25rem;
padding: 0 0.75rem;
height: var(--ui-height-md);
padding: 0 var(--ui-px-md);
font-size: var(--ui-font-md);
}
.ui-btn-lg {
height: var(--ui-height-lg);
padding: 0 var(--ui-px-lg);
font-size: var(--ui-font-lg);
}
.ui-btn-icon {
height: 2.25rem;
width: 2.25rem;
height: var(--ui-height-md);
width: var(--ui-height-md);
padding: 0;
}
.ui-btn-icon-sm {
height: var(--ui-height-sm);
width: var(--ui-height-sm);
padding: 0;
}
.ui-btn-icon-lg {
height: var(--ui-height-lg);
width: var(--ui-height-lg);
padding: 0;
}
@@ -357,9 +414,9 @@ pre,
background: transparent;
color: var(--text-primary);
cursor: pointer;
font-size: 13px;
font-size: var(--ui-font-sm);
line-height: 1.35;
min-height: 2rem;
min-height: var(--ui-height-sm);
padding: 0.5rem 0.75rem;
text-align: left;
}
+90 -25
View File
@@ -267,11 +267,23 @@ export default function Settings() {
</Card>
<Card className="space-y-3 p-4">
<h2 className="font-medium"></h2>
<Input placeholder="标题" value={siteForm.title} onChange={(e) => setSiteForm((f) => ({ ...f, title: e.target.value }))} />
<Input placeholder="副标题" value={siteForm.subtitle} onChange={(e) => setSiteForm((f) => ({ ...f, subtitle: e.target.value }))} />
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]"></label>
<Input placeholder="任务池" value={siteForm.title} onChange={(e) => setSiteForm((f) => ({ ...f, title: e.target.value }))} />
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]"></label>
<Input placeholder="轻量级任务调度平台" value={siteForm.subtitle} onChange={(e) => setSiteForm((f) => ({ ...f, subtitle: e.target.value }))} />
</div>
<div className="grid gap-3 md:grid-cols-2">
<Input placeholder="分页大小" value={siteForm.page_size} onChange={(e) => setSiteForm((f) => ({ ...f, page_size: e.target.value }))} />
<Input placeholder="Cookie 天数" value={siteForm.cookie_days} onChange={(e) => setSiteForm((f) => ({ ...f, cookie_days: e.target.value }))} />
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]"></label>
<Input placeholder="20" value={siteForm.page_size} onChange={(e) => setSiteForm((f) => ({ ...f, page_size: e.target.value }))} />
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]"> Cookie </label>
<Input placeholder="7" value={siteForm.cookie_days} onChange={(e) => setSiteForm((f) => ({ ...f, cookie_days: e.target.value }))} />
</div>
</div>
<label className="flex items-center justify-between text-sm">
<span> OpenAPI</span>
@@ -284,35 +296,75 @@ export default function Settings() {
</Button>
</div>
<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-1 font-medium text-[var(--text-primary)]">MCP ServerHermes / OpenClaw</div>
<div className="mb-2 font-medium text-[var(--text-primary)]">MCP ServerAI Agent </div>
<p className="mb-2">
OpenAPI REST AI Agent 使 <code className="rounded bg-[var(--bg-tertiary)] px-1">taskpool mcp</code> MCP
Server //
MCP Server OpenAPI 使AI Agent MCP //
</p>
<pre className="overflow-x-auto whitespace-pre-wrap rounded-md border border-[var(--border)] bg-[var(--bg-primary)] p-2 font-mono text-[11px] text-[var(--text-primary)]">{`{
<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": {
"command": "taskpool",
"args": ["mcp"],
"env": {
"TASKPOOL_URL": "${window.location.origin}",
"TASKPOOL_TOKEN": "${siteForm.openapi_token || '在设置页生成 OpenAPI Token'}"
"url": "${window.location.origin}/mcp",
"headers": {
"Authorization": "Bearer ${siteForm.openapi_token || '你的Token'}"
}
}
}
}`}</pre>
<p className="mt-2 text-[var(--text-muted)]"> docs/guide/mcp.md skills/taskpool/SKILL.md</p>
</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)]">stdio </div>
<p className="mb-2 text-[var(--text-muted)]"> Agent 使 taskpool Claude Desktop / Cursor</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": {
"command": "taskpool",
"args": ["mcp"]
}
}
}`}</pre>
</div>
</div>
<p className="text-[var(--text-muted)]"> docs/guide/mcp.md skills/taskpool/SKILL.md</p>
</div>
<h3 className="pt-2 text-sm font-medium text-[var(--text-secondary)]"></h3>
<p className="text-xs text-[var(--text-muted)]"></p>
<div className="grid gap-3 md:grid-cols-2">
<Input placeholder="系统通知保留天" value={siteForm.system_notice_days} onChange={(e) => setSiteForm((f) => ({ ...f, system_notice_days: e.target.value }))} />
<Input placeholder="系统通知最大条数" value={siteForm.system_notice_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, system_notice_max_count: e.target.value }))} />
<Input placeholder="推送日志保留天" value={siteForm.push_log_days} onChange={(e) => setSiteForm((f) => ({ ...f, push_log_days: e.target.value }))} />
<Input placeholder="推送日志最大条数" value={siteForm.push_log_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, push_log_max_count: e.target.value }))} />
<Input placeholder="登录日志保留天" value={siteForm.login_log_days} onChange={(e) => setSiteForm((f) => ({ ...f, login_log_days: e.target.value }))} />
<Input placeholder="登录日志最大条数" value={siteForm.login_log_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, login_log_max_count: e.target.value }))} />
<Input placeholder="调度日志保留天" value={siteForm.scheduler_log_days} onChange={(e) => setSiteForm((f) => ({ ...f, scheduler_log_days: e.target.value }))} />
<Input placeholder="调度日志最大条数" value={siteForm.scheduler_log_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, scheduler_log_max_count: e.target.value }))} />
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]"></label>
<Input placeholder="7" value={siteForm.system_notice_days} onChange={(e) => setSiteForm((f) => ({ ...f, system_notice_days: e.target.value }))} />
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]"></label>
<Input placeholder="1000" value={siteForm.system_notice_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, system_notice_max_count: e.target.value }))} />
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]"></label>
<Input placeholder="7" value={siteForm.push_log_days} onChange={(e) => setSiteForm((f) => ({ ...f, push_log_days: e.target.value }))} />
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]"></label>
<Input placeholder="1000" value={siteForm.push_log_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, push_log_max_count: e.target.value }))} />
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]"></label>
<Input placeholder="30" value={siteForm.login_log_days} onChange={(e) => setSiteForm((f) => ({ ...f, login_log_days: e.target.value }))} />
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]"></label>
<Input placeholder="1000" value={siteForm.login_log_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, login_log_max_count: e.target.value }))} />
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]"></label>
<Input placeholder="7" value={siteForm.scheduler_log_days} onChange={(e) => setSiteForm((f) => ({ ...f, scheduler_log_days: e.target.value }))} />
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]"></label>
<Input placeholder="1000" value={siteForm.scheduler_log_max_count} onChange={(e) => setSiteForm((f) => ({ ...f, scheduler_log_max_count: e.target.value }))} />
</div>
</div>
<Button onClick={() => saveSite.mutate()} disabled={saveSite.isPending}>
@@ -408,10 +460,23 @@ export default function Settings() {
{tab === 'scheduler' ? (
<Card className="space-y-3 p-4">
<h2 className="font-medium"></h2>
<p className="text-xs text-[var(--text-muted)]"></p>
<div className="grid gap-3 md:grid-cols-3">
<Input placeholder="Worker 数" value={schedulerForm.worker_count} onChange={(e) => setSchedulerForm((f) => ({ ...f, worker_count: e.target.value }))} />
<Input placeholder="队列大小" value={schedulerForm.queue_size} onChange={(e) => setSchedulerForm((f) => ({ ...f, queue_size: e.target.value }))} />
<Input placeholder="限速间隔" value={schedulerForm.rate_interval} onChange={(e) => setSchedulerForm((f) => ({ ...f, rate_interval: e.target.value }))} />
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]">Worker </label>
<Input placeholder="4" value={schedulerForm.worker_count} onChange={(e) => setSchedulerForm((f) => ({ ...f, worker_count: e.target.value }))} />
<p className="mt-1 text-xs text-[var(--text-muted)]"></p>
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]"></label>
<Input placeholder="100" value={schedulerForm.queue_size} onChange={(e) => setSchedulerForm((f) => ({ ...f, queue_size: e.target.value }))} />
<p className="mt-1 text-xs text-[var(--text-muted)]"></p>
</div>
<div>
<label className="mb-1 block text-xs text-[var(--text-muted)]"></label>
<Input placeholder="0" value={schedulerForm.rate_interval} onChange={(e) => setSchedulerForm((f) => ({ ...f, rate_interval: e.target.value }))} />
<p className="mt-1 text-xs text-[var(--text-muted)]">0 </p>
</div>
</div>
<Button onClick={() => saveScheduler.mutate()} disabled={saveScheduler.isPending}>