Files
ToNav-go/handlers/mcp.go
T
openclaw c707667a84 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 注入)
2026-09-11 06:33:28 +08:00

665 lines
21 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 (
"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
}