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