feat: 原生集成 MCP Server (POST /mcp, JSON-RPC 2.0, Bearer Token)

- Streamable HTTP 传输,支持 initialize/ping/tools/list/tools/call 及批量调用
- 6 个工具: add_service(自动抓 title/description/favicon)、create_category、
  list_categories(含服务计数)、list_services(keyword/category/tag 过滤)、
  update_service、delete_service
- 鉴权: TONAV_MCP_TOKEN 环境变量 (systemd 注入)
This commit is contained in:
2026-09-11 06:33:28 +08:00
parent 58ed199636
commit c707667a84
2 changed files with 669 additions and 0 deletions
+665
View File
@@ -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 <TONAV_MCP_TOKEN>
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 时会尝试抓取目标网页的 <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"},
},
"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), "</title>"); e >= 0 {
inner := rest[len("<title"):e]
if c := strings.Index(inner, ">"); 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, &params); 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
}
+4
View File
@@ -18,6 +18,7 @@ func main() {
database.Seed() database.Seed()
handlers.StartHealthCheck() handlers.StartHealthCheck()
handlers.StartAutoBackup() handlers.StartAutoBackup()
handlers.LoadMCPConfig()
r := gin.Default() r := gin.Default()
store := cookie.NewStore([]byte(cfg.SecretKey)) store := cookie.NewStore([]byte(cfg.SecretKey))
@@ -70,6 +71,9 @@ func main() {
admin.POST("/api/backup/restore", handlers.RestoreCloudBackup) 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) log.Printf("ToNav-go 启动在端口 %s...", cfg.Port)
r.Run(":" + cfg.Port) r.Run(":" + cfg.Port)
} }