From 9d7282074ff7ac6f49e2b4dcd18ea4bde12a0db6 Mon Sep 17 00:00:00 2001 From: openclaw Date: Fri, 11 Sep 2026 06:48:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20MCP=20v2=20=E2=80=94=20SSE=20=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E7=AB=AF=E7=82=B9=E3=80=81=E7=8B=AC=E7=AB=8B=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E5=B7=A5=E5=85=B7=E3=80=81=E7=A7=BB=E9=99=A4=20token?= =?UTF-8?q?=20=E9=BB=98=E8=AE=A4=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 个、双端点说明、部署变量表) --- README.md | 56 +++++++++++ handlers/mcp.go | 240 ++++++++++++++++++++++++++++++++++++++++++------ main.go | 14 ++- 3 files changed, 280 insertions(+), 30 deletions(-) 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 <TONAV_MCP_TOKEN>`,缺失或错误返回 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 <TONAV_MCP_TOKEN> 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 时会尝试抓取目标网页的 <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"}, + "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)