diff --git a/README.md b/README.md
index e70c63a..3176878 100644
--- a/README.md
+++ b/README.md
@@ -32,6 +32,16 @@
- ⏰ **定时自动备份** — 可配置每天凌晨 3:00 自动执行
- 📁 **本地备份管理** — 自动保留最近 5 份本地备份
+### MCP Server(AI 集成)
+- 🤖 **原生 MCP Server v2** — Streamable HTTP 双端点:`POST /mcp`(请求-响应) + `GET /mcp`(SSE 流式)
+- 📡 **服务端主动推送** — 服务/分类变更时通过 SSE 推送 `notifications/tools/list_changed`
+- 🔑 **Bearer Token 鉴权** — 通过 `TONAV_MCP_TOKEN` 环境变量配置;**未设置时 MCP 端点禁用(返回 503),无默认兜底**
+- 🧰 **7 个工具** — 添加服务 / 创建分类 / 列出分类 / 列出服务 / 搜索服务 / 更新服务 / 删除服务
+- 🔎 **全文搜索** — `tonav_search_services` 独立搜索工具,按名称>标签>描述>URL 相关度排序
+- 📝 **智能添加** — 添加服务不传名称时自动抓取目标网页 `
` 与 meta description,图标默认取 favicon
+- 🏷️ **分类自动创建** — 指定分类不存在时自动创建
+- 📦 **批量调用** — 支持单条与批量 JSON-RPC 请求,通知类请求返回 202
+
## 技术栈
| 组件 | 技术 |
@@ -43,6 +53,7 @@
| 认证 | bcrypt + Cookie Session |
| 前端 | 原生 HTML/CSS/JS |
| 备份 | WebDAV 协议 |
+| MCP | JSON-RPC 2.0(Streamable HTTP POST + SSE GET,Bearer Token 鉴权) |
## 项目结构
@@ -63,6 +74,7 @@ ToNav-go/
│ ├── health.go # 健康检查定时任务
│ ├── settings.go # 系统设置 & 备份/恢复/删除
│ ├── views.go # 页面渲染(首页/Dashboard/管理页面)
+│ ├── mcp.go # MCP Server v2(POST/GET /mcp, JSON-RPC 2.0 + SSE, Bearer Token)
│ └── utils.go # 工具函数(Session 获取)
├── utils/
│ ├── config.go # 配置加载(环境变量)
@@ -106,6 +118,7 @@ docker compose up -d
| `TONAV_PORT` | `9520` | 监听端口 |
| `TONAV_DB` | `/app/data/tonav.db` | 数据库路径 |
| `TONAV_SECRET` | 内置密钥 | Cookie 签名密钥(生产环境务必修改) |
+| `TONAV_MCP_TOKEN` | **未设置则 MCP 禁用** | MCP 接口鉴权 Token(必须配置,无默认值) |
| `TZ` | `Asia/Shanghai` | 时区 |
**数据持久化:**
@@ -162,6 +175,7 @@ chmod +x tonav-go-ctl.sh
| `TONAV_PORT` | `9520` | 监听端口 |
| `TONAV_DB` | `tonav.db` | 数据库文件路径 |
| `TONAV_SECRET` | 内置密钥 | Cookie 签名密钥 |
+| `TONAV_MCP_TOKEN` | **未设置则 MCP 禁用** | MCP 接口鉴权 Token(必须配置) |
### 默认账号
@@ -197,6 +211,48 @@ chmod +x tonav-go-ctl.sh
| DELETE | `/admin/api/backup/delete?name=xxx` | 删除云端备份 |
| POST | `/admin/api/backup/restore?name=xxx` | 恢复云端备份 |
+### MCP 接口(Bearer Token 鉴权)
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| POST | `/mcp` | MCP Streamable HTTP 端点(JSON-RPC 2.0 请求-响应) |
+| GET | `/mcp` | MCP SSE 流式端点(服务端主动推送通知) |
+
+- 鉴权:请求头 `Authorization: Bearer `,缺失或错误返回 401
+- **未配置 `TONAV_MCP_TOKEN` 时端点返回 503(MCP 禁用),启动日志会警告**
+- 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":["代码"]}}}'
+```
+
## 数据模型
### Service 服务
diff --git a/handlers/mcp.go b/handlers/mcp.go
index c4aeafa..aad1c0b 100644
--- a/handlers/mcp.go
+++ b/handlers/mcp.go
@@ -8,6 +8,7 @@ import (
"os"
"strconv"
"strings"
+ "sync"
"time"
"tonav-go/database"
"tonav-go/models"
@@ -15,8 +16,8 @@ import (
"github.com/gin-gonic/gin"
)
-// ============ MCP (Model Context Protocol) v1 ============
-// 传输: Streamable HTTP (POST /mcp, JSON-RPC 2.0)
+// ============ MCP (Model Context Protocol) v2 ============
+// 传输: Streamable HTTP (POST /mcp) + SSE (GET /mcp),JSON-RPC 2.0
// 鉴权: Authorization: Bearer
type mcpConfig struct {
@@ -26,13 +27,18 @@ type mcpConfig struct {
var mcpToken string
// LoadMCPConfig 读取 MCP 配置(环境变量 TONAV_MCP_TOKEN)
-func LoadMCPConfig() {
+// v2: 未设置时返回错误,拒绝默认 token 兜底(防裸奔)
+func LoadMCPConfig() error {
mcpToken = os.Getenv("TONAV_MCP_TOKEN")
if mcpToken == "" {
- mcpToken = "tonav-mcp-default-token"
+ return fmt.Errorf("TONAV_MCP_TOKEN 未设置:MCP 端点已禁用。请设置环境变量后重启,或在 systemd 单元中配置 Environment=TONAV_MCP_TOKEN=<随机token>")
}
+ return nil
}
+// MCPEnabled 报告 MCP 端点是否可用
+func MCPEnabled() bool { return mcpToken != "" }
+
// MCPAuthRequired MCP Bearer Token 鉴权
func MCPAuthRequired() gin.HandlerFunc {
return func(c *gin.Context) {
@@ -86,20 +92,20 @@ type mcpTool struct {
func mcpToolDefs() []mcpTool {
return []mcpTool{
{
- Name: "tonav_add_service",
+ Name: "tonav_add_service",
Description: "向 ToNav 导航页添加一个服务网址。可指定名称、分类(不存在则自动创建)、标签、描述、是否开启健康检查。不传 name 时会尝试抓取目标网页的 自动命名。",
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"},
+ "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"},
},
@@ -142,19 +148,31 @@ func mcpToolDefs() []mcpTool {
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"},
+ "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 删除一个服务",
@@ -367,6 +385,10 @@ func toolAddService(args map[string]interface{}) (interface{}, error) {
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 {
@@ -376,8 +398,13 @@ func toolAddService(args map[string]interface{}) (interface{}, error) {
}
}
return map[string]interface{}{
- "created": true,
- "message": fmt.Sprintf("已添加服务「%s」%s", svc.Name, func() string { if catName != "" { return " 到分类「" + catName + "」" }; return "" }()),
+ "created": true,
+ "message": fmt.Sprintf("已添加服务「%s」%s", svc.Name, func() string {
+ if catName != "" {
+ return " 到分类「" + catName + "」"
+ }
+ return ""
+ }()),
"service": svc,
"category_id": categoryID,
"category": catName,
@@ -407,6 +434,10 @@ func toolCreateCategory(args map[string]interface{}) (interface{}, error) {
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),
@@ -457,6 +488,61 @@ func toolListServices(args map[string]interface{}) (interface{}, error) {
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)
@@ -519,6 +605,10 @@ func toolDeleteService(args map[string]interface{}) (interface{}, error) {
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
}
@@ -550,6 +640,8 @@ func callTool(name string, args map[string]interface{}) (interface{}, error) {
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":
@@ -561,6 +653,10 @@ func callTool(name string, args map[string]interface{}) (interface{}, error) {
// MCPHandler 处理 POST /mcp (Streamable HTTP)
func MCPHandler(c *gin.Context) {
+ if mcpToken == "" {
+ c.JSON(http.StatusServiceUnavailable, mcpError(nil, -32000, "MCP 未启用:未配置 TONAV_MCP_TOKEN"))
+ return
+ }
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1024*1024))
if err != nil {
c.JSON(http.StatusBadRequest, mcpError(nil, -32700, "读取请求失败"))
@@ -612,7 +708,7 @@ func handleMCPRequest(req jsonRPCRequest) gin.H {
},
"serverInfo": map[string]interface{}{
"name": "tonav-mcp",
- "version": "1.0.0",
+ "version": "2.0.0",
},
})
@@ -662,4 +758,92 @@ func handleMCPRequest(req jsonRPCRequest) gin.H {
// GetMCPHeaderToken 供测试使用
func GetMCPHeaderToken() string {
return mcpToken
-}
\ No newline at end of file
+}
+
+// ============ 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) {
+ if mcpToken == "" {
+ c.JSON(http.StatusServiceUnavailable, mcpError(nil, -32000, "MCP 未启用:未配置 TONAV_MCP_TOKEN"))
+ 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",
+ })
+}
diff --git a/main.go b/main.go
index 2648c72..1815de8 100644
--- a/main.go
+++ b/main.go
@@ -18,7 +18,9 @@ func main() {
database.Seed()
handlers.StartHealthCheck()
handlers.StartAutoBackup()
- handlers.LoadMCPConfig()
+ if err := handlers.LoadMCPConfig(); err != nil {
+ log.Printf("⚠️ MCP 未启用: %v", err)
+ }
r := gin.Default()
store := cookie.NewStore([]byte(cfg.SecretKey))
@@ -72,7 +74,15 @@ func main() {
}
// MCP (Model Context Protocol) 端点
- r.POST("/mcp", handlers.MCPAuthRequired(), handlers.MCPHandler)
+ // POST: 请求-响应模式;GET: SSE 流式(服务端主动推送 notifications/tools/list_changed)
+ if handlers.MCPEnabled() {
+ r.POST("/mcp", handlers.MCPAuthRequired(), handlers.MCPHandler)
+ r.GET("/mcp", handlers.MCPAuthRequired(), handlers.MCPSSEHandler)
+ } else {
+ // 未配置 token 时仍注册端点,但返回 503 明确提示
+ r.POST("/mcp", handlers.MCPHandler)
+ r.GET("/mcp", handlers.MCPSSEHandler)
+ }
log.Printf("ToNav-go 启动在端口 %s...", cfg.Port)
r.Run(":" + cfg.Port)