937 lines
29 KiB
Go
937 lines
29 KiB
Go
package handlers
|
||
|
||
import (
|
||
cryptoRand "crypto/rand"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
"tonav-go/database"
|
||
"tonav-go/models"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// ============ MCP (Model Context Protocol) v2.1 ============
|
||
// 传输: Streamable HTTP (POST /mcp) + SSE (GET /mcp),JSON-RPC 2.0
|
||
// 鉴权: Authorization: Bearer <token>;开关与 Token 存于 settings 表(后台 UI 管理),动态读取即时生效
|
||
|
||
// getMCPAuthState 从数据库读取 MCP 开关与 Token(每次请求动态读,改动即时生效)
|
||
func getMCPAuthState() (enabled bool, token string) {
|
||
var enabledSetting, tokenSetting models.Setting
|
||
database.DB.Where("key = ?", "mcp_enabled").First(&enabledSetting)
|
||
database.DB.Where("key = ?", "mcp_token").First(&tokenSetting)
|
||
return enabledSetting.Value == "true", tokenSetting.Value
|
||
}
|
||
|
||
// SetupMCP 初始化 MCP 配置(启动时调用:把环境变量 Token 迁移进数据库作为初始值)
|
||
func SetupMCP() {
|
||
var s models.Setting
|
||
if err := database.DB.Where("key = ?", "mcp_token").First(&s).Error; err != nil {
|
||
// 数据库无 token:用环境变量值初始化(兼容旧部署),没有则留空(后台生成)
|
||
database.DB.Save(&models.Setting{Key: "mcp_token", Value: os.Getenv("TONAV_MCP_TOKEN")})
|
||
}
|
||
if err := database.DB.Where("key = ?", "mcp_enabled").First(&s).Error; err != nil {
|
||
var tok models.Setting
|
||
database.DB.Where("key = ?", "mcp_token").First(&tok)
|
||
v := "false"
|
||
if tok.Value != "" {
|
||
v = "true"
|
||
}
|
||
database.DB.Save(&models.Setting{Key: "mcp_enabled", Value: v})
|
||
}
|
||
}
|
||
|
||
// MCPAuthRequired MCP Bearer Token 鉴权(动态读数据库:后台改开关/Token 即时生效,无需重启)
|
||
func MCPAuthRequired() gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
enabled, token := getMCPAuthState()
|
||
if !enabled || token == "" {
|
||
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{
|
||
"jsonrpc": "2.0",
|
||
"error": gin.H{
|
||
"code": -32000,
|
||
"message": "MCP 未启用:请在后台设置中开启 MCP Server 并生成 Token",
|
||
},
|
||
"id": nil,
|
||
})
|
||
return
|
||
}
|
||
auth := c.GetHeader("Authorization")
|
||
tok := strings.TrimPrefix(auth, "Bearer ")
|
||
if tok == "" || tok != token {
|
||
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_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 删除一个服务",
|
||
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
|
||
}
|
||
|
||
tagsStr := ""
|
||
if rawTags, ok := args["tags"]; ok && rawTags != nil {
|
||
if arr, ok := rawTags.([]interface{}); ok {
|
||
tagsStr = tagsToString(arr)
|
||
}
|
||
}
|
||
|
||
svc := models.Service{
|
||
Name: strings.TrimSpace(name),
|
||
URL: url,
|
||
Description: strings.TrimSpace(description),
|
||
Icon: icon,
|
||
CategoryID: categoryID,
|
||
Status: "unknown",
|
||
Tags: tagsStr,
|
||
IsEnabled: isEnabled,
|
||
HealthCheckURL: strings.TrimSpace(healthURL),
|
||
HealthCheckEnabled: healthEnabled,
|
||
}
|
||
|
||
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 {
|
||
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)
|
||
}
|
||
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),
|
||
"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
|
||
}
|
||
|
||
// 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)
|
||
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)
|
||
}
|
||
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
|
||
}
|
||
|
||
// ============ 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_search_services":
|
||
return toolSearchServices(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) {
|
||
enabled, token := getMCPAuthState()
|
||
if !enabled || token == "" {
|
||
c.JSON(http.StatusServiceUnavailable, mcpError(nil, -32000, "MCP 未启用:请在后台设置中开启"))
|
||
return
|
||
}
|
||
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": "2.1.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)
|
||
}
|
||
}
|
||
|
||
// ============ 后台 MCP 管理 API (v2.1) ============
|
||
|
||
// MCPStatus 后台查询 MCP 状态(Token 掩码显示)
|
||
func MCPStatus(c *gin.Context) {
|
||
enabled, token := getMCPAuthState()
|
||
masked := ""
|
||
if token != "" {
|
||
if len(token) > 10 {
|
||
masked = token[:6] + "…" + token[len(token)-4:]
|
||
} else {
|
||
masked = "****"
|
||
}
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"enabled": enabled,
|
||
"has_token": token != "",
|
||
"token_masked": masked,
|
||
})
|
||
}
|
||
|
||
// MCPGetToken 返回完整明文 Token(供后台「复制」按钮使用,需已登录后台会话)
|
||
func MCPGetToken(c *gin.Context) {
|
||
_, token := getMCPAuthState()
|
||
if token == "" {
|
||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "Token 未生成,请先点「重新生成」"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "token": token})
|
||
}
|
||
|
||
// MCPRegenerateToken 生成新随机 Token 并自动启用 MCP,返回完整新 Token(仅此一次明文展示)
|
||
func MCPRegenerateToken(c *gin.Context) {
|
||
b := make([]byte, 24)
|
||
if _, err := cryptoRand.Read(b); err != nil {
|
||
c.JSON(http.StatusInternalServerError, Response{Success: false, Message: "生成失败: " + err.Error()})
|
||
return
|
||
}
|
||
token := hex.EncodeToString(b)
|
||
database.DB.Save(&models.Setting{Key: "mcp_token", Value: token})
|
||
database.DB.Save(&models.Setting{Key: "mcp_enabled", Value: "true"})
|
||
c.JSON(http.StatusOK, gin.H{"success": true, "token": token, "message": "已生成新 Token 并启用 MCP"})
|
||
}
|
||
|
||
// MCPSetEnabled 开启/关闭 MCP(Token 为空时拒绝开启)
|
||
func MCPSetEnabled(c *gin.Context) {
|
||
var input struct {
|
||
Enabled bool `json:"enabled"`
|
||
}
|
||
if err := c.ShouldBindJSON(&input); err != nil {
|
||
c.JSON(http.StatusBadRequest, Response{Success: false, Message: err.Error()})
|
||
return
|
||
}
|
||
if input.Enabled {
|
||
_, token := getMCPAuthState()
|
||
if token == "" {
|
||
c.JSON(http.StatusBadRequest, Response{Success: false, Message: "Token 为空,请先生成 Token"})
|
||
return
|
||
}
|
||
}
|
||
database.DB.Save(&models.Setting{Key: "mcp_enabled", Value: strconv.FormatBool(input.Enabled)})
|
||
msg := "MCP 已关闭"
|
||
if input.Enabled {
|
||
msg = "MCP 已开启,即时生效"
|
||
}
|
||
c.JSON(http.StatusOK, Response{Success: true, Message: msg})
|
||
}
|
||
|
||
// GetMCPHeaderToken 供测试使用
|
||
func GetMCPHeaderToken() string {
|
||
_, token := getMCPAuthState()
|
||
return token
|
||
}
|
||
|
||
// ============ 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) {
|
||
enabled, token := getMCPAuthState()
|
||
if !enabled || token == "" {
|
||
c.JSON(http.StatusServiceUnavailable, mcpError(nil, -32000, "MCP 未启用:请在后台设置中开启"))
|
||
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",
|
||
})
|
||
}
|