Files
ToNav-go/handlers/mcp.go
T

937 lines
29 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, &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)
}
}
// ============ 后台 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",
})
}