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 个、双端点说明、部署变量表)
This commit is contained in:
+212
-28
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 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",
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user