package handlers import ( "encoding/json" "fmt" "io" "net/http" "os" "strconv" "strings" "time" "tonav-go/database" "tonav-go/models" "github.com/gin-gonic/gin" ) // ============ MCP (Model Context Protocol) v1 ============ // 传输: Streamable HTTP (POST /mcp, JSON-RPC 2.0) // 鉴权: Authorization: Bearer type mcpConfig struct { Token string } var mcpToken string // LoadMCPConfig 读取 MCP 配置(环境变量 TONAV_MCP_TOKEN) func LoadMCPConfig() { mcpToken = os.Getenv("TONAV_MCP_TOKEN") if mcpToken == "" { mcpToken = "tonav-mcp-default-token" } } // MCPAuthRequired MCP Bearer Token 鉴权 func MCPAuthRequired() gin.HandlerFunc { return func(c *gin.Context) { auth := c.GetHeader("Authorization") token := strings.TrimPrefix(auth, "Bearer ") if token == "" || token != mcpToken { 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 时会尝试抓取目标网页的 自动命名。", 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_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), ""); e >= 0 { inner := rest[len(""); 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) } 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) } 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 } // 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) } 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_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) { 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": "1.0.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, ¶ms); 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) } } // GetMCPHeaderToken 供测试使用 func GetMCPHeaderToken() string { return mcpToken }