diff --git a/handlers/mcp.go b/handlers/mcp.go new file mode 100644 index 0000000..c4aeafa --- /dev/null +++ b/handlers/mcp.go @@ -0,0 +1,665 @@ +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 +} \ No newline at end of file diff --git a/main.go b/main.go index 03c73ba..2648c72 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ func main() { database.Seed() handlers.StartHealthCheck() handlers.StartAutoBackup() + handlers.LoadMCPConfig() r := gin.Default() store := cookie.NewStore([]byte(cfg.SecretKey)) @@ -70,6 +71,9 @@ func main() { admin.POST("/api/backup/restore", handlers.RestoreCloudBackup) } + // MCP (Model Context Protocol) 端点 + r.POST("/mcp", handlers.MCPAuthRequired(), handlers.MCPHandler) + log.Printf("ToNav-go 启动在端口 %s...", cfg.Port) r.Run(":" + cfg.Port) }