4 Commits
Author SHA1 Message Date
openclaw 861f132274 chore: 服务端版本 2.0.0 -> 2.1.0 2026-09-11 14:53:49 +08:00
openclaw 909387ef52 feat: MCP 管理进后台 UI (v2.1) — 开关/Token 即时生效无需重启
- settings 表新增 mcp_enabled / mcp_token,鉴权中间件动态读库,后台改动即时生效(实测验证:关->503、开->200、换 token 旧401新200,均不重启)
- 首次启动自动把 TONAV_MCP_TOKEN 环境变量迁移进数据库(兼容旧部署,systemd 现有 token 无感切换)
- 后台新增 API:GET /admin/api/mcp/status(token 掩码)、POST /admin/api/mcp/token(重新生成+自动启用,仅一次明文展示)、POST /admin/api/mcp/enabled
- 后台设置弹窗新增 MCP Server 区块:开关 checkbox、token 掩码展示、一键重新生成
- README 同步
2026-09-11 14:46:47 +08:00
openclaw 9d7282074f feat: MCP v2 — SSE 流式端点、独立搜索工具、移除 token 默认兜底
- GET /mcp SSE 端点:endpoint 握手 + notifications/tools/list_changed 实时推送(服务/分类增删触发),30s keepalive
- tonav_search_services 独立全文搜索工具,相关度排序(name>tags>description>url),支持 tag 收窄
- TONAV_MCP_TOKEN 未设置时 MCP 端点禁用(503)+ 启动警告,不再回退到硬编码默认 token
- 服务端版本号 1.0.0 -> 2.0.0
- README 同步更新(工具表 7 个、双端点说明、部署变量表)
2026-09-11 06:48:37 +08:00
openclaw c707667a84 feat: 原生集成 MCP Server (POST /mcp, JSON-RPC 2.0, Bearer Token)
- Streamable HTTP 传输,支持 initialize/ping/tools/list/tools/call 及批量调用
- 6 个工具: add_service(自动抓 title/description/favicon)、create_category、
  list_categories(含服务计数)、list_services(keyword/category/tag 过滤)、
  update_service、delete_service
- 鉴权: TONAV_MCP_TOKEN 环境变量 (systemd 注入)
2026-09-11 06:33:28 +08:00
4 changed files with 1063 additions and 0 deletions
+60
View File
@@ -32,6 +32,16 @@
- ⏰ **定时自动备份** — 可配置每天凌晨 3:00 自动执行 - ⏰ **定时自动备份** — 可配置每天凌晨 3:00 自动执行
- 📁 **本地备份管理** — 自动保留最近 5 份本地备份 - 📁 **本地备份管理** — 自动保留最近 5 份本地备份
### MCP Server(AI 集成)
- 🤖 **原生 MCP Server v2** — Streamable HTTP 双端点:`POST /mcp`(请求-响应) + `GET /mcp`(SSE 流式)
- 📡 **服务端主动推送** — 服务/分类变更时通过 SSE 推送 `notifications/tools/list_changed`
- 🔑 **Bearer Token 鉴权** — 开关与 Token 存于 settings 表,**后台 UI 管理,改动即时生效无需重启**(环境变量 TONAV_MCP_TOKEN 首次启动自动迁移进数据库)
- 🧰 **7 个工具** — 添加服务 / 创建分类 / 列出分类 / 列出服务 / 搜索服务 / 更新服务 / 删除服务
- 🔎 **全文搜索** — `tonav_search_services` 独立搜索工具,按名称>标签>描述>URL 相关度排序
- 📝 **智能添加** — 添加服务不传名称时自动抓取目标网页 `<title>` 与 meta description,图标默认取 favicon
- 🏷️ **分类自动创建** — 指定分类不存在时自动创建
- 📦 **批量调用** — 支持单条与批量 JSON-RPC 请求,通知类请求返回 202
## 技术栈 ## 技术栈
| 组件 | 技术 | | 组件 | 技术 |
@@ -43,6 +53,7 @@
| 认证 | bcrypt + Cookie Session | | 认证 | bcrypt + Cookie Session |
| 前端 | 原生 HTML/CSS/JS | | 前端 | 原生 HTML/CSS/JS |
| 备份 | WebDAV 协议 | | 备份 | WebDAV 协议 |
| MCP | JSON-RPC 2.0(Streamable HTTP POST + SSE GET,Bearer Token 鉴权) |
## 项目结构 ## 项目结构
@@ -63,6 +74,7 @@ ToNav-go/
│ ├── health.go # 健康检查定时任务 │ ├── health.go # 健康检查定时任务
│ ├── settings.go # 系统设置 & 备份/恢复/删除 │ ├── settings.go # 系统设置 & 备份/恢复/删除
│ ├── views.go # 页面渲染(首页/Dashboard/管理页面) │ ├── views.go # 页面渲染(首页/Dashboard/管理页面)
│ ├── mcp.go # MCP Server v2(POST/GET /mcp, JSON-RPC 2.0 + SSE, Bearer Token)
│ └── utils.go # 工具函数(Session 获取) │ └── utils.go # 工具函数(Session 获取)
├── utils/ ├── utils/
│ ├── config.go # 配置加载(环境变量) │ ├── config.go # 配置加载(环境变量)
@@ -106,6 +118,7 @@ docker compose up -d
| `TONAV_PORT` | `9520` | 监听端口 | | `TONAV_PORT` | `9520` | 监听端口 |
| `TONAV_DB` | `/app/data/tonav.db` | 数据库路径 | | `TONAV_DB` | `/app/data/tonav.db` | 数据库路径 |
| `TONAV_SECRET` | 内置密钥 | Cookie 签名密钥(生产环境务必修改) | | `TONAV_SECRET` | 内置密钥 | Cookie 签名密钥(生产环境务必修改) |
| `TONAV_MCP_TOKEN` | 无 | **仅首启初始化用**:有值则迁移进数据库并自动启用 MCP;之后以后台设置为准 |
| `TZ` | `Asia/Shanghai` | 时区 | | `TZ` | `Asia/Shanghai` | 时区 |
**数据持久化:** **数据持久化:**
@@ -162,6 +175,7 @@ chmod +x tonav-go-ctl.sh
| `TONAV_PORT` | `9520` | 监听端口 | | `TONAV_PORT` | `9520` | 监听端口 |
| `TONAV_DB` | `tonav.db` | 数据库文件路径 | | `TONAV_DB` | `tonav.db` | 数据库文件路径 |
| `TONAV_SECRET` | 内置密钥 | Cookie 签名密钥 | | `TONAV_SECRET` | 内置密钥 | Cookie 签名密钥 |
| `TONAV_MCP_TOKEN` | 无 | 仅首启初始化用,之后以后台设置为准 |
### 默认账号 ### 默认账号
@@ -196,6 +210,52 @@ chmod +x tonav-go-ctl.sh
| GET | `/admin/api/backup/list` | 列出云端备份 | | GET | `/admin/api/backup/list` | 列出云端备份 |
| DELETE | `/admin/api/backup/delete?name=xxx` | 删除云端备份 | | DELETE | `/admin/api/backup/delete?name=xxx` | 删除云端备份 |
| POST | `/admin/api/backup/restore?name=xxx` | 恢复云端备份 | | POST | `/admin/api/backup/restore?name=xxx` | 恢复云端备份 |
| GET | `/admin/api/mcp/status` | MCP 状态(Token 掩码) |
| POST | `/admin/api/mcp/token` | 重新生成 MCP Token(自动启用) |
| POST | `/admin/api/mcp/enabled` | 开启/关闭 MCP |
### MCP 接口(Bearer Token 鉴权)
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/mcp` | MCP Streamable HTTP 端点(JSON-RPC 2.0 请求-响应) |
| GET | `/mcp` | MCP SSE 流式端点(服务端主动推送通知) |
- 鉴权:请求头 `Authorization: Bearer <token>`,缺失或错误返回 401
- **开关与 Token 在后台设置页管理(⚙️ 系统设置 → MCP Server),动态读数据库,改动即时生效无需重启**
- 开关关闭或未生成 Token 时端点返回 503
- POST 支持 `initialize` / `ping` / `tools/list` / `tools/call` 方法;通知类请求返回 202
- POST 支持单条与批量 JSON-RPC 请求
- GET 为 SSE 流:首个事件 `endpoint: /mcp`(握手),后续 `message` 事件推送 `notifications/tools/list_changed`(服务/分类增删时触发),每 30s keepalive
**MCP 工具(7 个):**
| 工具 | 说明 |
|------|------|
| `tonav_add_service` | 添加服务(自动抓取网页 title/描述/favicon,分类不存在自动创建,同 URL 查重) |
| `tonav_create_category` | 创建分类 |
| `tonav_list_categories` | 列出所有分类(含服务数量) |
| `tonav_list_services` | 列出服务,支持关键词 / 分类 / 标签过滤 |
| `tonav_search_services` | 全文搜索服务,按名称>标签>描述>URL 相关度排序,支持标签收窄 |
| `tonav_update_service` | 按名称或 ID 更新服务 |
| `tonav_delete_service` | 按名称或 ID 删除服务 |
**调用示例:**
```bash
TOKEN="your-mcp-token"
# 列出全部工具
curl -s -X POST http://127.0.0.1:9520/mcp \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# 添加服务(不传 name 时自动抓取网页标题)
curl -s -X POST http://127.0.0.1:9520/mcp \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"tonav_add_service","arguments":{"url":"https://github.com","category":"开发","tags":["代码"]}}}'
```
## 数据模型 ## 数据模型
+930
View File
@@ -0,0 +1,930 @@
package handlers
import (
cryptoRand "crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"tonav-go/database"
"tonav-go/models"
"github.com/gin-gonic/gin"
)
// ============ MCP (Model Context Protocol) v2.1 ============
// 传输: Streamable HTTP (POST /mcp) + SSE (GET /mcp),JSON-RPC 2.0
// 鉴权: Authorization: Bearer <token>;开关与 Token 存于 settings 表(后台 UI 管理),动态读取即时生效
// getMCPAuthState 从数据库读取 MCP 开关与 Token(每次请求动态读,改动即时生效)
func getMCPAuthState() (enabled bool, token string) {
var enabledSetting, tokenSetting models.Setting
database.DB.Where("key = ?", "mcp_enabled").First(&enabledSetting)
database.DB.Where("key = ?", "mcp_token").First(&tokenSetting)
return enabledSetting.Value == "true", tokenSetting.Value
}
// SetupMCP 初始化 MCP 配置(启动时调用:把环境变量 Token 迁移进数据库作为初始值)
func SetupMCP() {
var s models.Setting
if err := database.DB.Where("key = ?", "mcp_token").First(&s).Error; err != nil {
// 数据库无 token:用环境变量值初始化(兼容旧部署),没有则留空(后台生成)
database.DB.Save(&models.Setting{Key: "mcp_token", Value: os.Getenv("TONAV_MCP_TOKEN")})
}
if err := database.DB.Where("key = ?", "mcp_enabled").First(&s).Error; err != nil {
var tok models.Setting
database.DB.Where("key = ?", "mcp_token").First(&tok)
v := "false"
if tok.Value != "" {
v = "true"
}
database.DB.Save(&models.Setting{Key: "mcp_enabled", Value: v})
}
}
// MCPAuthRequired MCP Bearer Token 鉴权(动态读数据库:后台改开关/Token 即时生效,无需重启)
func MCPAuthRequired() gin.HandlerFunc {
return func(c *gin.Context) {
enabled, token := getMCPAuthState()
if !enabled || token == "" {
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{
"jsonrpc": "2.0",
"error": gin.H{
"code": -32000,
"message": "MCP 未启用:请在后台设置中开启 MCP Server 并生成 Token",
},
"id": nil,
})
return
}
auth := c.GetHeader("Authorization")
tok := strings.TrimPrefix(auth, "Bearer ")
if tok == "" || tok != token {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"jsonrpc": "2.0",
"error": gin.H{
"code": -32001,
"message": "unauthorized: invalid or missing bearer token",
},
"id": nil,
})
return
}
c.Next()
}
}
// ============ JSON-RPC 结构 ============
type jsonRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type jsonRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
ID interface{} `json:"id"`
Result interface{} `json:"result,omitempty"`
Error *jsonRPCError `json:"error,omitempty"`
}
type jsonRPCError struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// ============ MCP 工具定义 ============
type mcpTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema map[string]interface{} `json:"inputSchema"`
}
func mcpToolDefs() []mcpTool {
return []mcpTool{
{
Name: "tonav_add_service",
Description: "向 ToNav 导航页添加一个服务网址。可指定名称、分类(不存在则自动创建)、标签、描述、是否开启健康检查。不传 name 时会尝试抓取目标网页的 <title> 自动命名。",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"url": map[string]interface{}{"type": "string", "description": "服务网址,必填"},
"name": map[string]interface{}{"type": "string", "description": "服务名称,不填自动抓取网页 title"},
"category": map[string]interface{}{"type": "string", "description": "分类名称,不存在时自动创建"},
"tags": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "标签列表,如 [\"监控\",\"告警\"]"},
"description": map[string]interface{}{"type": "string", "description": "服务描述,不填自动抓取网页 meta description"},
"icon": map[string]interface{}{"type": "string", "description": "图标 URL,不填自动使用 /favicon.ico"},
"health_check_enabled": map[string]interface{}{"type": "boolean", "description": "是否开启健康检查,默认 true"},
"health_check_url": map[string]interface{}{"type": "string", "description": "健康检查地址,默认同 url"},
"is_enabled": map[string]interface{}{"type": "boolean", "description": "是否启用显示,默认 true"},
},
"required": []string{"url"},
},
},
{
Name: "tonav_create_category",
Description: "在 ToNav 中创建一个分类",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"name": map[string]interface{}{"type": "string", "description": "分类名称,必填"},
"sort_order": map[string]interface{}{"type": "integer", "description": "排序权重,数字越大越靠前,默认 0"},
},
"required": []string{"name"},
},
},
{
Name: "tonav_list_categories",
Description: "列出 ToNav 所有分类(含每个分类下的服务数量)",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
},
{
Name: "tonav_list_services",
Description: "列出 ToNav 服务,可按关键词/分类名/标签过滤",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"keyword": map[string]interface{}{"type": "string", "description": "按名称/URL/描述模糊匹配"},
"category": map[string]interface{}{"type": "string", "description": "按分类名过滤"},
"tag": map[string]interface{}{"type": "string", "description": "按标签过滤"},
},
},
},
{
Name: "tonav_update_service",
Description: "按名称或 ID 更新已有服务:可改名称/URL/分类/标签/描述/启用状态/健康检查开关",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{"type": "integer", "description": "服务 ID(与 name 二选一)"},
"name": map[string]interface{}{"type": "string", "description": "现有服务名称(用于定位服务)"},
"new_name": map[string]interface{}{"type": "string", "description": "新名称"},
"url": map[string]interface{}{"type": "string", "description": "新 URL"},
"category": map[string]interface{}{"type": "string", "description": "新分类名(不存在自动创建)"},
"tags": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "新标签列表(整体替换)"},
"description": map[string]interface{}{"type": "string", "description": "新描述"},
"is_enabled": map[string]interface{}{"type": "boolean"},
"health_check_enabled": map[string]interface{}{"type": "boolean"},
"health_check_url": map[string]interface{}{"type": "string"},
},
},
},
{
Name: "tonav_search_services",
Description: "全文搜索 ToNav 服务:按关键词匹配名称/URL/描述/标签,返回按相关度排序的结果。比 tonav_list_services 更适合查找特定服务。",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"query": map[string]interface{}{"type": "string", "description": "搜索关键词,必填"},
"tag": map[string]interface{}{"type": "string", "description": "可选:按标签收窄结果"},
},
"required": []string{"query"},
},
},
{
Name: "tonav_delete_service",
Description: "按名称或 ID 删除一个服务",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{"type": "integer", "description": "服务 ID(与 name 二选一)"},
"name": map[string]interface{}{"type": "string", "description": "服务名称"},
},
},
},
}
}
// ============ 工具实现 ============
// fetchPageMeta 抓取网页 <title> 与 meta description
func fetchPageMeta(rawURL string) (title, desc string) {
client := &http.Client{Timeout: 6 * time.Second}
if !strings.HasPrefix(rawURL, "http") {
rawURL = "https://" + rawURL
}
resp, err := client.Get(rawURL)
if err != nil {
return "", ""
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
if err != nil {
return "", ""
}
html := string(body)
// title
if s := strings.Index(strings.ToLower(html), "<title"); s >= 0 {
rest := html[s:]
if e := strings.Index(strings.ToLower(rest), "</title>"); e >= 0 {
inner := rest[len("<title"):e]
if c := strings.Index(inner, ">"); c >= 0 {
title = strings.TrimSpace(inner[c+1:])
}
}
}
// meta description
lower := strings.ToLower(html)
if idx := strings.Index(lower, "name=\"description\""); idx >= 0 {
seg := html[idx:]
if cIdx := strings.Index(strings.ToLower(seg), "content=\""); cIdx >= 0 {
seg = seg[cIdx+len("content=\""):]
if eIdx := strings.Index(seg, "\""); eIdx >= 0 {
desc = strings.TrimSpace(seg[:eIdx])
}
}
}
if len(title) > 200 {
title = title[:200]
}
if len(desc) > 500 {
desc = desc[:500]
}
return title, desc
}
// normalizeURL 规范化 URL
func normalizeURL(u string) string {
u = strings.TrimSpace(u)
if u != "" && !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
u = "https://" + u
}
return u
}
// ensureCategory 获取或创建分类,返回分类 ID
func ensureCategory(name string) (uint, error) {
var cat models.Category
if err := database.DB.Where("name = ?", name).First(&cat).Error; err == nil {
return cat.ID, nil
}
cat = models.Category{Name: name, SortOrder: 50}
if err := database.DB.Create(&cat).Error; err != nil {
// 并发或重名冲突时再查一次
if err2 := database.DB.Where("name = ?", name).First(&cat).Error; err2 == nil {
return cat.ID, nil
}
return 0, err
}
return cat.ID, nil
}
// findService 按 ID 或名称定位服务
func findService(id float64, name string) (*models.Service, error) {
var svc models.Service
var err error
if id > 0 {
err = database.DB.First(&svc, uint(id)).Error
} else if name != "" {
err = database.DB.Where("name = ?", name).First(&svc).Error
} else {
err = fmt.Errorf("必须提供 id 或 name")
}
if err != nil {
return nil, err
}
return &svc, nil
}
// tagsToString 标签数组转存储格式(逗号分隔)
func tagsToString(tags []interface{}) string {
if len(tags) == 0 {
return ""
}
parts := make([]string, 0, len(tags))
for _, t := range tags {
if s, ok := t.(string); ok && strings.TrimSpace(s) != "" {
parts = append(parts, strings.TrimSpace(s))
}
}
return strings.Join(parts, ",")
}
// toolAddService 添加服务
func toolAddService(args map[string]interface{}) (interface{}, error) {
rawURL, _ := args["url"].(string)
if strings.TrimSpace(rawURL) == "" {
return nil, fmt.Errorf("url 不能为空")
}
url := normalizeURL(rawURL)
name, _ := args["name"].(string)
description, _ := args["description"].(string)
category, _ := args["category"].(string)
// 自动抓取 title / description
autoTitle, autoDesc := "", ""
if strings.TrimSpace(name) == "" || strings.TrimSpace(description) == "" {
autoTitle, autoDesc = fetchPageMeta(url)
}
if strings.TrimSpace(name) == "" {
name = autoTitle
}
if strings.TrimSpace(name) == "" {
// 兜底用 host
u := strings.TrimPrefix(strings.TrimPrefix(url, "https://"), "http://")
name = strings.Split(u, "/")[0]
}
if strings.TrimSpace(description) == "" {
description = autoDesc
}
healthEnabled := true
if v, ok := args["health_check_enabled"].(bool); ok {
healthEnabled = v
}
isEnabled := true
if v, ok := args["is_enabled"].(bool); ok {
isEnabled = v
}
healthURL, _ := args["health_check_url"].(string)
var categoryID uint
if strings.TrimSpace(category) != "" {
cid, err := ensureCategory(strings.TrimSpace(category))
if err != nil {
return nil, fmt.Errorf("创建分类失败: %v", err)
}
categoryID = cid
}
icon, _ := args["icon"].(string)
if strings.TrimSpace(icon) == "" {
u := strings.TrimPrefix(strings.TrimPrefix(url, "https://"), "http://")
host := strings.Split(u, "/")[0]
icon = "https://" + host + "/favicon.ico"
}
// 查重:同名或同 URL 已存在则返回提示
var existed models.Service
if err := database.DB.Where("url = ?", url).First(&existed).Error; err == nil {
return map[string]interface{}{
"created": false,
"message": fmt.Sprintf("已存在相同 URL 的服务「%s」(id=%d),未重复创建。可使用 tonav_update_service 更新它。", existed.Name, existed.ID),
"existing": existed,
}, nil
}
svc := models.Service{
Name: strings.TrimSpace(name),
URL: url,
Description: strings.TrimSpace(description),
Icon: icon,
CategoryID: categoryID,
Status: "unknown",
Tags: tagsToString(args["tags"].([]interface{})),
IsEnabled: isEnabled,
HealthCheckURL: strings.TrimSpace(healthURL),
HealthCheckEnabled: healthEnabled,
}
// tags 需要单独处理(args["tags"] 可能不存在)
if rawTags, ok := args["tags"]; ok {
if arr, ok := rawTags.([]interface{}); ok {
svc.Tags = tagsToString(arr)
}
}
if svc.Tags == "" {
if arr, ok := args["tags"].([]interface{}); ok {
svc.Tags = tagsToString(arr)
}
}
if err := database.DB.Create(&svc).Error; err != nil {
return nil, fmt.Errorf("创建失败: %v", err)
}
notifySSE("message", map[string]interface{}{
"jsonrpc": "2.0",
"method": "notifications/tools/list_changed",
})
catName := category
if catName == "" && categoryID > 0 {
var cat models.Category
if database.DB.First(&cat, categoryID).Error == nil {
catName = cat.Name
}
}
return map[string]interface{}{
"created": true,
"message": fmt.Sprintf("已添加服务「%s」%s", svc.Name, func() string {
if catName != "" {
return " 到分类「" + catName + "」"
}
return ""
}()),
"service": svc,
"category_id": categoryID,
"category": catName,
"tags": svc.Tags,
}, nil
}
// toolCreateCategory 创建分类
func toolCreateCategory(args map[string]interface{}) (interface{}, error) {
name, _ := args["name"].(string)
if strings.TrimSpace(name) == "" {
return nil, fmt.Errorf("name 不能为空")
}
sortOrder := 0
if v, ok := args["sort_order"].(float64); ok {
sortOrder = int(v)
}
var existed models.Category
if err := database.DB.Where("name = ?", name).First(&existed).Error; err == nil {
return map[string]interface{}{
"created": false,
"message": fmt.Sprintf("分类「%s」已存在 (id=%d)", existed.Name, existed.ID),
"category": existed,
}, nil
}
cat := models.Category{Name: strings.TrimSpace(name), SortOrder: sortOrder}
if err := database.DB.Create(&cat).Error; err != nil {
return nil, fmt.Errorf("创建分类失败: %v", err)
}
notifySSE("message", map[string]interface{}{
"jsonrpc": "2.0",
"method": "notifications/tools/list_changed",
})
return map[string]interface{}{
"created": true,
"message": fmt.Sprintf("已创建分类「%s」", cat.Name),
"category": cat,
}, nil
}
// toolListCategories 列出分类
func toolListCategories(_ map[string]interface{}) (interface{}, error) {
var cats []models.Category
database.DB.Order("sort_order desc").Find(&cats)
type catWithCount struct {
models.Category
ServiceCount int64 `json:"service_count"`
}
result := make([]catWithCount, 0, len(cats))
for _, c := range cats {
var cnt int64
database.DB.Model(&models.Service{}).Where("category_id = ?", c.ID).Count(&cnt)
result = append(result, catWithCount{Category: c, ServiceCount: cnt})
}
return map[string]interface{}{"categories": result}, nil
}
// toolListServices 列出服务
func toolListServices(args map[string]interface{}) (interface{}, error) {
var svcs []models.Service
q := database.DB.Order("category_id asc, sort_order desc")
keyword, _ := args["keyword"].(string)
category, _ := args["category"].(string)
tag, _ := args["tag"].(string)
if category != "" {
var cat models.Category
if err := database.DB.Where("name = ?", category).First(&cat).Error; err != nil {
return map[string]interface{}{"services": []models.Service{}, "message": "分类不存在: " + category}, nil
}
q = q.Where("category_id = ?", cat.ID)
}
if tag != "" {
q = q.Where("tags LIKE ?", "%"+tag+"%")
}
if keyword != "" {
like := "%" + keyword + "%"
q = q.Where("name LIKE ? OR url LIKE ? OR description LIKE ?", like, like, like)
}
q.Find(&svcs)
return map[string]interface{}{"count": len(svcs), "services": svcs}, nil
}
// toolSearchServices 全文搜索服务(v2 独立搜索工具)
func toolSearchServices(args map[string]interface{}) (interface{}, error) {
query, _ := args["query"].(string)
query = strings.TrimSpace(query)
if query == "" {
return nil, fmt.Errorf("query 不能为空")
}
var svcs []models.Service
q := database.DB.Order("sort_order desc")
if tag, _ := args["tag"].(string); strings.TrimSpace(tag) != "" {
q = q.Where("tags LIKE ?", "%"+strings.TrimSpace(tag)+"%")
}
like := "%" + query + "%"
q = q.Where(
database.DB.Where("name LIKE ?", like).Or("url LIKE ?", like).
Or("description LIKE ?", like).Or("tags LIKE ?", like),
)
q.Find(&svcs)
// 计算简单相关度评分:名称匹配 > 标签匹配 > 描述匹配 > URL 匹配
type scored struct {
models.Service
Score int `json:"score"`
Match string `json:"matched_field"`
}
results := make([]scored, 0, len(svcs))
for _, s := range svcs {
sc := scored{Service: s}
ql := strings.ToLower(query)
switch {
case strings.Contains(strings.ToLower(s.Name), ql):
sc.Score, sc.Match = 4, "name"
case strings.Contains(strings.ToLower(s.Tags), ql):
sc.Score, sc.Match = 3, "tags"
case strings.Contains(strings.ToLower(s.Description), ql):
sc.Score, sc.Match = 2, "description"
default:
sc.Score, sc.Match = 1, "url"
}
results = append(results, sc)
}
for i := 0; i < len(results); i++ {
for j := i + 1; j < len(results); j++ {
if results[j].Score > results[i].Score {
results[i], results[j] = results[j], results[i]
}
}
}
return map[string]interface{}{
"query": query,
"count": len(results),
"services": results,
}, nil
}
// toolUpdateService 更新服务
func toolUpdateService(args map[string]interface{}) (interface{}, error) {
id, _ := args["id"].(float64)
name, _ := args["name"].(string)
svc, err := findService(id, name)
if err != nil {
return nil, fmt.Errorf("未找到服务: %v", err)
}
changes := map[string]interface{}{}
if v, ok := args["new_name"].(string); ok && strings.TrimSpace(v) != "" {
changes["name"] = strings.TrimSpace(v)
}
if v, ok := args["url"].(string); ok && strings.TrimSpace(v) != "" {
changes["url"] = normalizeURL(v)
}
if v, ok := args["description"].(string); ok {
changes["description"] = v
}
if v, ok := args["health_check_url"].(string); ok {
changes["health_check_url"] = strings.TrimSpace(v)
}
if v, ok := args["is_enabled"].(bool); ok {
changes["is_enabled"] = v
}
if v, ok := args["health_check_enabled"].(bool); ok {
changes["health_check_enabled"] = v
}
if v, ok := args["tags"]; ok {
if arr, ok := v.([]interface{}); ok {
changes["tags"] = tagsToString(arr)
}
}
if cat, ok := args["category"].(string); ok && strings.TrimSpace(cat) != "" {
cid, err := ensureCategory(strings.TrimSpace(cat))
if err != nil {
return nil, fmt.Errorf("创建分类失败: %v", err)
}
changes["category_id"] = cid
}
if len(changes) == 0 {
return nil, fmt.Errorf("未提供任何需要更新的字段")
}
if err := database.DB.Model(svc).Updates(changes).Error; err != nil {
return nil, fmt.Errorf("更新失败: %v", err)
}
database.DB.First(svc, svc.ID)
return map[string]interface{}{"updated": true, "message": fmt.Sprintf("已更新服务「%s」", svc.Name), "service": svc}, nil
}
// toolDeleteService 删除服务
func toolDeleteService(args map[string]interface{}) (interface{}, error) {
id, _ := args["id"].(float64)
name, _ := args["name"].(string)
svc, err := findService(id, name)
if err != nil {
return nil, fmt.Errorf("未找到服务: %v", err)
}
if err := database.DB.Delete(svc).Error; err != nil {
return nil, fmt.Errorf("删除失败: %v", err)
}
notifySSE("message", map[string]interface{}{
"jsonrpc": "2.0",
"method": "notifications/tools/list_changed",
})
return map[string]interface{}{"deleted": true, "message": fmt.Sprintf("已删除服务「%s」(id=%d)", svc.Name, svc.ID)}, nil
}
// ============ JSON-RPC 处理 ============
func mcpResult(id json.RawMessage, result interface{}) gin.H {
var idVal interface{}
if len(id) > 0 {
_ = json.Unmarshal(id, &idVal)
}
return gin.H{"jsonrpc": "2.0", "id": idVal, "result": result}
}
func mcpError(id json.RawMessage, code int, msg string) gin.H {
var idVal interface{}
if len(id) > 0 {
_ = json.Unmarshal(id, &idVal)
}
return gin.H{"jsonrpc": "2.0", "id": idVal, "error": gin.H{"code": code, "message": msg}}
}
func callTool(name string, args map[string]interface{}) (interface{}, error) {
switch name {
case "tonav_add_service":
return toolAddService(args)
case "tonav_create_category":
return toolCreateCategory(args)
case "tonav_list_categories":
return toolListCategories(args)
case "tonav_list_services":
return toolListServices(args)
case "tonav_search_services":
return toolSearchServices(args)
case "tonav_update_service":
return toolUpdateService(args)
case "tonav_delete_service":
return toolDeleteService(args)
default:
return nil, fmt.Errorf("未知工具: %s", name)
}
}
// MCPHandler 处理 POST /mcp (Streamable HTTP)
func MCPHandler(c *gin.Context) {
enabled, token := getMCPAuthState()
if !enabled || token == "" {
c.JSON(http.StatusServiceUnavailable, mcpError(nil, -32000, "MCP 未启用:请在后台设置中开启"))
return
}
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1024*1024))
if err != nil {
c.JSON(http.StatusBadRequest, mcpError(nil, -32700, "读取请求失败"))
return
}
// 支持批量调用
var batch []jsonRPCRequest
var single jsonRPCRequest
if err := json.Unmarshal(body, &batch); err == nil && len(batch) > 0 {
responses := make([]gin.H, 0, len(batch))
for _, req := range batch {
if resp := handleMCPRequest(req); resp != nil {
responses = append(responses, resp)
}
}
if len(responses) == 0 {
c.Status(http.StatusAccepted)
return
}
c.JSON(http.StatusOK, responses)
return
}
if err := json.Unmarshal(body, &single); err != nil {
c.JSON(http.StatusBadRequest, mcpError(nil, -32700, "解析错误: "+err.Error()))
return
}
resp := handleMCPRequest(single)
if resp == nil {
// notification -> 202 Accepted
c.Status(http.StatusAccepted)
return
}
c.JSON(http.StatusOK, resp)
}
// handleMCPRequest 处理单条 JSON-RPC 请求;notification 返回 nil
func handleMCPRequest(req jsonRPCRequest) gin.H {
switch req.Method {
case "initialize":
proto, _ := strconv.Unquote(string(req.Params))
_ = proto
return mcpResult(req.ID, map[string]interface{}{
"protocolVersion": "2025-03-26",
"capabilities": map[string]interface{}{
"tools": map[string]interface{}{"listChanged": false},
},
"serverInfo": map[string]interface{}{
"name": "tonav-mcp",
"version": "2.1.0",
},
})
case "notifications/initialized", "initialized":
return nil // notification
case "ping":
return mcpResult(req.ID, map[string]interface{}{})
case "tools/list":
return mcpResult(req.ID, map[string]interface{}{"tools": mcpToolDefs()})
case "tools/call":
var params struct {
Name string `json:"name"`
Arguments map[string]interface{} `json:"arguments"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
return mcpError(req.ID, -32602, "参数解析失败: "+err.Error())
}
if params.Arguments == nil {
params.Arguments = map[string]interface{}{}
}
result, err := callTool(params.Name, params.Arguments)
if err != nil {
return mcpResult(req.ID, map[string]interface{}{
"content": []map[string]interface{}{{
"type": "text",
"text": "工具执行失败: " + err.Error(),
}},
"isError": true,
})
}
data, _ := json.Marshal(result)
return mcpResult(req.ID, map[string]interface{}{
"content": []map[string]interface{}{{
"type": "text",
"text": string(data),
}},
})
default:
return mcpError(req.ID, -32601, "未知方法: "+req.Method)
}
}
// ============ 后台 MCP 管理 API (v2.1) ============
// MCPStatus 后台查询 MCP 状态(Token 掩码显示)
func MCPStatus(c *gin.Context) {
enabled, token := getMCPAuthState()
masked := ""
if token != "" {
if len(token) > 10 {
masked = token[:6] + "…" + token[len(token)-4:]
} else {
masked = "****"
}
}
c.JSON(http.StatusOK, gin.H{
"enabled": enabled,
"has_token": token != "",
"token_masked": masked,
})
}
// MCPRegenerateToken 生成新随机 Token 并自动启用 MCP,返回完整新 Token(仅此一次明文展示)
func MCPRegenerateToken(c *gin.Context) {
b := make([]byte, 24)
if _, err := cryptoRand.Read(b); err != nil {
c.JSON(http.StatusInternalServerError, Response{Success: false, Message: "生成失败: " + err.Error()})
return
}
token := hex.EncodeToString(b)
database.DB.Save(&models.Setting{Key: "mcp_token", Value: token})
database.DB.Save(&models.Setting{Key: "mcp_enabled", Value: "true"})
c.JSON(http.StatusOK, gin.H{"success": true, "token": token, "message": "已生成新 Token 并启用 MCP"})
}
// MCPSetEnabled 开启/关闭 MCP(Token 为空时拒绝开启)
func MCPSetEnabled(c *gin.Context) {
var input struct {
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, Response{Success: false, Message: err.Error()})
return
}
if input.Enabled {
_, token := getMCPAuthState()
if token == "" {
c.JSON(http.StatusBadRequest, Response{Success: false, Message: "Token 为空,请先生成 Token"})
return
}
}
database.DB.Save(&models.Setting{Key: "mcp_enabled", Value: strconv.FormatBool(input.Enabled)})
msg := "MCP 已关闭"
if input.Enabled {
msg = "MCP 已开启,即时生效"
}
c.JSON(http.StatusOK, Response{Success: true, Message: msg})
}
// GetMCPHeaderToken 供测试使用
func GetMCPHeaderToken() string {
_, token := getMCPAuthState()
return token
}
// ============ SSE 端点 (GET /mcp, v2) ============
// mcpEventCh SSE 事件广播通道(多客户端)
var (
sseMu sync.Mutex
sseClients = make(map[chan sseEvent]struct{})
)
type sseEvent struct {
Event string
Data interface{}
}
// notifySSE 向所有已连接 SSE 客户端广播一条通知(非阻塞)
func notifySSE(event string, data interface{}) {
sseMu.Lock()
defer sseMu.Unlock()
for ch := range sseClients {
select {
case ch <- sseEvent{Event: event, Data: data}:
default: // 慢客户端直接丢弃,不阻塞
}
}
}
// MCPSSEHandler 处理 GET /mcp (SSE 流式端点)
// 客户端可收到: endpoint(初始化握手)、message(服务端主动通知)等事件
func MCPSSEHandler(c *gin.Context) {
enabled, token := getMCPAuthState()
if !enabled || token == "" {
c.JSON(http.StatusServiceUnavailable, mcpError(nil, -32000, "MCP 未启用:请在后台设置中开启"))
return
}
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Header().Set("X-Accel-Buffering", "no")
c.Writer.WriteHeader(http.StatusOK)
ch := make(chan sseEvent, 16)
sseMu.Lock()
sseClients[ch] = struct{}{}
sseMu.Unlock()
defer func() {
sseMu.Lock()
delete(sseClients, ch)
sseMu.Unlock()
}()
// 初始 endpoint 事件:告知客户端 POST 端点地址
writeSSE(c, "endpoint", "/mcp")
c.Writer.Flush()
ticker := time.NewTicker(30 * time.Second) // keepalive 注释行
defer ticker.Stop()
for {
select {
case ev := <-ch:
writeSSE(c, ev.Event, ev.Data)
c.Writer.Flush()
case <-ticker.C:
// SSE 注释行作为 keepalive
_, _ = c.Writer.WriteString(": keepalive\n\n")
c.Writer.Flush()
case <-c.Request.Context().Done():
return
}
}
}
func writeSSE(c *gin.Context, event string, data interface{}) {
dataBytes, err := json.Marshal(data)
if err != nil {
return
}
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, dataBytes)
}
// MCPNotificationSender 供业务代码在服务/分类变更时推送通知
// 规范:notifications/tools/list_changed
func NotifyToolsChanged() {
notifySSE("message", map[string]interface{}{
"jsonrpc": "2.0",
"method": "notifications/tools/list_changed",
})
}
+12
View File
@@ -18,6 +18,7 @@ func main() {
database.Seed() database.Seed()
handlers.StartHealthCheck() handlers.StartHealthCheck()
handlers.StartAutoBackup() handlers.StartAutoBackup()
handlers.SetupMCP()
r := gin.Default() r := gin.Default()
store := cookie.NewStore([]byte(cfg.SecretKey)) store := cookie.NewStore([]byte(cfg.SecretKey))
@@ -68,8 +69,19 @@ func main() {
admin.GET("/api/backup/list", handlers.ListCloudBackups) admin.GET("/api/backup/list", handlers.ListCloudBackups)
admin.DELETE("/api/backup/delete", handlers.DeleteCloudBackup) admin.DELETE("/api/backup/delete", handlers.DeleteCloudBackup)
admin.POST("/api/backup/restore", handlers.RestoreCloudBackup) admin.POST("/api/backup/restore", handlers.RestoreCloudBackup)
// MCP 管理(后台 UI)
admin.GET("/api/mcp/status", handlers.MCPStatus)
admin.POST("/api/mcp/token", handlers.MCPRegenerateToken)
admin.POST("/api/mcp/enabled", handlers.MCPSetEnabled)
} }
// MCP (Model Context Protocol) 端点
// POST: 请求-响应模式;GET: SSE 流式(服务端主动推送 notifications/tools/list_changed)
// 鉴权中间件动态读数据库:后台开关/Token 改动即时生效,无需重启
r.POST("/mcp", handlers.MCPAuthRequired(), handlers.MCPHandler)
r.GET("/mcp", handlers.MCPAuthRequired(), handlers.MCPSSEHandler)
log.Printf("ToNav-go 启动在端口 %s...", cfg.Port) log.Printf("ToNav-go 启动在端口 %s...", cfg.Port)
r.Run(":" + cfg.Port) r.Run(":" + cfg.Port)
} }
+61
View File
@@ -129,6 +129,24 @@
</div> </div>
</div> </div>
<div style="margin-bottom:20px; padding:15px; background:#f9f9f9; border-radius:10px">
<h3 style="font-size:14px; margin-bottom:10px">🤖 MCP Server</h3>
<div style="display:flex; align-items:center; gap:8px; margin-bottom:10px">
<input type="checkbox" id="cfg_mcp_enabled" style="width:16px; height:16px" onchange="toggleMCP()">
<label for="cfg_mcp_enabled" style="margin:0; font-size:13px">启用 MCP 接口(AI 可通过它管理导航页)</label>
</div>
<div id="mcpTokenRow" style="display:none">
<label style="font-size:12px; color:#666">Token</label>
<div style="display:flex; gap:8px; align-items:center">
<input type="text" id="cfg_mcp_token" class="input" readonly style="flex:1; background:#fff; color:#555">
<button class="btn btn-primary" onclick="regenMCPToken()" style="padding:8px 14px; white-space:nowrap">🔄 重新生成</button>
</div>
<div style="font-size:11px; color:#999; margin-top:6px">
端点: <code>POST/GET /mcp</code>(Streamable HTTP + SSE)· 改动即时生效,无需重启
</div>
</div>
</div>
<button class="btn btn-primary" onclick="saveSettings()" style="width:100%; margin-bottom:15px">保存设置</button> <button class="btn btn-primary" onclick="saveSettings()" style="width:100%; margin-bottom:15px">保存设置</button>
<div style="padding:15px; border:1px solid #eee; border-radius:10px"> <div style="padding:15px; border:1px solid #eee; border-radius:10px">
@@ -150,9 +168,52 @@
document.getElementById('cfg_pass').value = data.webdav_password || ''; document.getElementById('cfg_pass').value = data.webdav_password || '';
document.getElementById('cfg_auto_backup').checked = data.auto_backup === 'true'; document.getElementById('cfg_auto_backup').checked = data.auto_backup === 'true';
document.getElementById('settingsModal').style.display = 'flex'; document.getElementById('settingsModal').style.display = 'flex';
loadMCPStatus();
loadCloudBackups(); loadCloudBackups();
} }
async function loadMCPStatus() {
try {
const resp = await fetch('/admin/api/mcp/status');
const d = await resp.json();
const cb = document.getElementById('cfg_mcp_enabled');
const row = document.getElementById('mcpTokenRow');
cb.checked = d.enabled;
row.style.display = d.enabled ? 'block' : 'none';
document.getElementById('cfg_mcp_token').value = d.token_masked || '(未生成,点重新生成创建)';
} catch(e) {}
}
function toggleMCP() {
const enabled = document.getElementById('cfg_mcp_enabled').checked;
document.getElementById('mcpTokenRow').style.display = enabled ? 'block' : 'none';
fetch('/admin/api/mcp/enabled', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({enabled: enabled})
}).then(r => r.json()).then(res => {
if (!res.success) {
alert(res.message);
document.getElementById('cfg_mcp_enabled').checked = !enabled;
document.getElementById('mcpTokenRow').style.display = enabled ? 'none' : 'block';
}
});
}
async function regenMCPToken() {
if (!confirm('确定重新生成 Token?\n\n旧 Token 将立即失效,已接入的 AI 客户端需要更新为新 Token。')) return;
try {
const resp = await fetch('/admin/api/mcp/token', {method: 'POST'});
const res = await resp.json();
if (res.success) {
alert('✅ 新 Token 已生成(仅此一次明文展示,请立即复制):\n\n' + res.token);
loadMCPStatus();
} else {
alert('❌ ' + res.message);
}
} catch(e) { alert('生成失败'); }
}
async function loadCloudBackups() { async function loadCloudBackups() {
const listDiv = document.getElementById('backupList'); const listDiv = document.getElementById('backupList');
try { try {