Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
861f132274 | ||
|
|
909387ef52 | ||
|
|
9d7282074f | ||
|
|
c707667a84 |
@@ -32,6 +32,16 @@
|
|||||||
- ⏰ **定时自动备份** — 可配置每天凌晨 3:00 自动执行
|
- ⏰ **定时自动备份** — 可配置每天凌晨 3:00 自动执行
|
||||||
- 📁 **本地备份管理** — 自动保留最近 5 份本地备份
|
- 📁 **本地备份管理** — 自动保留最近 5 份本地备份
|
||||||
|
|
||||||
|
### MCP Server(AI 集成)
|
||||||
|
- 🤖 **原生 MCP Server v2** — Streamable HTTP 双端点:`POST /mcp`(请求-响应) + `GET /mcp`(SSE 流式)
|
||||||
|
- 📡 **服务端主动推送** — 服务/分类变更时通过 SSE 推送 `notifications/tools/list_changed`
|
||||||
|
- 🔑 **Bearer Token 鉴权** — 开关与 Token 存于 settings 表,**后台 UI 管理,改动即时生效无需重启**(环境变量 TONAV_MCP_TOKEN 首次启动自动迁移进数据库)
|
||||||
|
- 🧰 **7 个工具** — 添加服务 / 创建分类 / 列出分类 / 列出服务 / 搜索服务 / 更新服务 / 删除服务
|
||||||
|
- 🔎 **全文搜索** — `tonav_search_services` 独立搜索工具,按名称>标签>描述>URL 相关度排序
|
||||||
|
- 📝 **智能添加** — 添加服务不传名称时自动抓取目标网页 `<title>` 与 meta description,图标默认取 favicon
|
||||||
|
- 🏷️ **分类自动创建** — 指定分类不存在时自动创建
|
||||||
|
- 📦 **批量调用** — 支持单条与批量 JSON-RPC 请求,通知类请求返回 202
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
| 组件 | 技术 |
|
| 组件 | 技术 |
|
||||||
@@ -43,6 +53,7 @@
|
|||||||
| 认证 | bcrypt + Cookie Session |
|
| 认证 | bcrypt + Cookie Session |
|
||||||
| 前端 | 原生 HTML/CSS/JS |
|
| 前端 | 原生 HTML/CSS/JS |
|
||||||
| 备份 | WebDAV 协议 |
|
| 备份 | WebDAV 协议 |
|
||||||
|
| MCP | JSON-RPC 2.0(Streamable HTTP POST + SSE GET,Bearer Token 鉴权) |
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
@@ -63,6 +74,7 @@ ToNav-go/
|
|||||||
│ ├── health.go # 健康检查定时任务
|
│ ├── health.go # 健康检查定时任务
|
||||||
│ ├── settings.go # 系统设置 & 备份/恢复/删除
|
│ ├── settings.go # 系统设置 & 备份/恢复/删除
|
||||||
│ ├── views.go # 页面渲染(首页/Dashboard/管理页面)
|
│ ├── views.go # 页面渲染(首页/Dashboard/管理页面)
|
||||||
|
│ ├── mcp.go # MCP Server v2(POST/GET /mcp, JSON-RPC 2.0 + SSE, Bearer Token)
|
||||||
│ └── utils.go # 工具函数(Session 获取)
|
│ └── utils.go # 工具函数(Session 获取)
|
||||||
├── utils/
|
├── utils/
|
||||||
│ ├── config.go # 配置加载(环境变量)
|
│ ├── config.go # 配置加载(环境变量)
|
||||||
@@ -106,6 +118,7 @@ docker compose up -d
|
|||||||
| `TONAV_PORT` | `9520` | 监听端口 |
|
| `TONAV_PORT` | `9520` | 监听端口 |
|
||||||
| `TONAV_DB` | `/app/data/tonav.db` | 数据库路径 |
|
| `TONAV_DB` | `/app/data/tonav.db` | 数据库路径 |
|
||||||
| `TONAV_SECRET` | 内置密钥 | Cookie 签名密钥(生产环境务必修改) |
|
| `TONAV_SECRET` | 内置密钥 | Cookie 签名密钥(生产环境务必修改) |
|
||||||
|
| `TONAV_MCP_TOKEN` | 无 | **仅首启初始化用**:有值则迁移进数据库并自动启用 MCP;之后以后台设置为准 |
|
||||||
| `TZ` | `Asia/Shanghai` | 时区 |
|
| `TZ` | `Asia/Shanghai` | 时区 |
|
||||||
|
|
||||||
**数据持久化:**
|
**数据持久化:**
|
||||||
@@ -162,6 +175,7 @@ chmod +x tonav-go-ctl.sh
|
|||||||
| `TONAV_PORT` | `9520` | 监听端口 |
|
| `TONAV_PORT` | `9520` | 监听端口 |
|
||||||
| `TONAV_DB` | `tonav.db` | 数据库文件路径 |
|
| `TONAV_DB` | `tonav.db` | 数据库文件路径 |
|
||||||
| `TONAV_SECRET` | 内置密钥 | Cookie 签名密钥 |
|
| `TONAV_SECRET` | 内置密钥 | Cookie 签名密钥 |
|
||||||
|
| `TONAV_MCP_TOKEN` | 无 | 仅首启初始化用,之后以后台设置为准 |
|
||||||
|
|
||||||
### 默认账号
|
### 默认账号
|
||||||
|
|
||||||
@@ -196,6 +210,52 @@ chmod +x tonav-go-ctl.sh
|
|||||||
| GET | `/admin/api/backup/list` | 列出云端备份 |
|
| GET | `/admin/api/backup/list` | 列出云端备份 |
|
||||||
| DELETE | `/admin/api/backup/delete?name=xxx` | 删除云端备份 |
|
| DELETE | `/admin/api/backup/delete?name=xxx` | 删除云端备份 |
|
||||||
| POST | `/admin/api/backup/restore?name=xxx` | 恢复云端备份 |
|
| POST | `/admin/api/backup/restore?name=xxx` | 恢复云端备份 |
|
||||||
|
| GET | `/admin/api/mcp/status` | MCP 状态(Token 掩码) |
|
||||||
|
| POST | `/admin/api/mcp/token` | 重新生成 MCP Token(自动启用) |
|
||||||
|
| POST | `/admin/api/mcp/enabled` | 开启/关闭 MCP |
|
||||||
|
|
||||||
|
### MCP 接口(Bearer Token 鉴权)
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| POST | `/mcp` | MCP Streamable HTTP 端点(JSON-RPC 2.0 请求-响应) |
|
||||||
|
| GET | `/mcp` | MCP SSE 流式端点(服务端主动推送通知) |
|
||||||
|
|
||||||
|
- 鉴权:请求头 `Authorization: Bearer <token>`,缺失或错误返回 401
|
||||||
|
- **开关与 Token 在后台设置页管理(⚙️ 系统设置 → MCP Server),动态读数据库,改动即时生效无需重启**
|
||||||
|
- 开关关闭或未生成 Token 时端点返回 503
|
||||||
|
- POST 支持 `initialize` / `ping` / `tools/list` / `tools/call` 方法;通知类请求返回 202
|
||||||
|
- POST 支持单条与批量 JSON-RPC 请求
|
||||||
|
- GET 为 SSE 流:首个事件 `endpoint: /mcp`(握手),后续 `message` 事件推送 `notifications/tools/list_changed`(服务/分类增删时触发),每 30s keepalive
|
||||||
|
|
||||||
|
**MCP 工具(7 个):**
|
||||||
|
|
||||||
|
| 工具 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `tonav_add_service` | 添加服务(自动抓取网页 title/描述/favicon,分类不存在自动创建,同 URL 查重) |
|
||||||
|
| `tonav_create_category` | 创建分类 |
|
||||||
|
| `tonav_list_categories` | 列出所有分类(含服务数量) |
|
||||||
|
| `tonav_list_services` | 列出服务,支持关键词 / 分类 / 标签过滤 |
|
||||||
|
| `tonav_search_services` | 全文搜索服务,按名称>标签>描述>URL 相关度排序,支持标签收窄 |
|
||||||
|
| `tonav_update_service` | 按名称或 ID 更新服务 |
|
||||||
|
| `tonav_delete_service` | 按名称或 ID 删除服务 |
|
||||||
|
|
||||||
|
**调用示例:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN="your-mcp-token"
|
||||||
|
|
||||||
|
# 列出全部工具
|
||||||
|
curl -s -X POST http://127.0.0.1:9520/mcp \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
|
||||||
|
|
||||||
|
# 添加服务(不传 name 时自动抓取网页标题)
|
||||||
|
curl -s -X POST http://127.0.0.1:9520/mcp \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"tonav_add_service","arguments":{"url":"https://github.com","category":"开发","tags":["代码"]}}}'
|
||||||
|
```
|
||||||
|
|
||||||
## 数据模型
|
## 数据模型
|
||||||
|
|
||||||
|
|||||||
+930
@@ -0,0 +1,930 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ func main() {
|
|||||||
database.Seed()
|
database.Seed()
|
||||||
handlers.StartHealthCheck()
|
handlers.StartHealthCheck()
|
||||||
handlers.StartAutoBackup()
|
handlers.StartAutoBackup()
|
||||||
|
handlers.SetupMCP()
|
||||||
|
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
store := cookie.NewStore([]byte(cfg.SecretKey))
|
store := cookie.NewStore([]byte(cfg.SecretKey))
|
||||||
@@ -68,8 +69,19 @@ func main() {
|
|||||||
admin.GET("/api/backup/list", handlers.ListCloudBackups)
|
admin.GET("/api/backup/list", handlers.ListCloudBackups)
|
||||||
admin.DELETE("/api/backup/delete", handlers.DeleteCloudBackup)
|
admin.DELETE("/api/backup/delete", handlers.DeleteCloudBackup)
|
||||||
admin.POST("/api/backup/restore", handlers.RestoreCloudBackup)
|
admin.POST("/api/backup/restore", handlers.RestoreCloudBackup)
|
||||||
|
|
||||||
|
// MCP 管理(后台 UI)
|
||||||
|
admin.GET("/api/mcp/status", handlers.MCPStatus)
|
||||||
|
admin.POST("/api/mcp/token", handlers.MCPRegenerateToken)
|
||||||
|
admin.POST("/api/mcp/enabled", handlers.MCPSetEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MCP (Model Context Protocol) 端点
|
||||||
|
// POST: 请求-响应模式;GET: SSE 流式(服务端主动推送 notifications/tools/list_changed)
|
||||||
|
// 鉴权中间件动态读数据库:后台开关/Token 改动即时生效,无需重启
|
||||||
|
r.POST("/mcp", handlers.MCPAuthRequired(), handlers.MCPHandler)
|
||||||
|
r.GET("/mcp", handlers.MCPAuthRequired(), handlers.MCPSSEHandler)
|
||||||
|
|
||||||
log.Printf("ToNav-go 启动在端口 %s...", cfg.Port)
|
log.Printf("ToNav-go 启动在端口 %s...", cfg.Port)
|
||||||
r.Run(":" + cfg.Port)
|
r.Run(":" + cfg.Port)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:20px; padding:15px; background:#f9f9f9; border-radius:10px">
|
||||||
|
<h3 style="font-size:14px; margin-bottom:10px">🤖 MCP Server</h3>
|
||||||
|
<div style="display:flex; align-items:center; gap:8px; margin-bottom:10px">
|
||||||
|
<input type="checkbox" id="cfg_mcp_enabled" style="width:16px; height:16px" onchange="toggleMCP()">
|
||||||
|
<label for="cfg_mcp_enabled" style="margin:0; font-size:13px">启用 MCP 接口(AI 可通过它管理导航页)</label>
|
||||||
|
</div>
|
||||||
|
<div id="mcpTokenRow" style="display:none">
|
||||||
|
<label style="font-size:12px; color:#666">Token</label>
|
||||||
|
<div style="display:flex; gap:8px; align-items:center">
|
||||||
|
<input type="text" id="cfg_mcp_token" class="input" readonly style="flex:1; background:#fff; color:#555">
|
||||||
|
<button class="btn btn-primary" onclick="regenMCPToken()" style="padding:8px 14px; white-space:nowrap">🔄 重新生成</button>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px; color:#999; margin-top:6px">
|
||||||
|
端点: <code>POST/GET /mcp</code>(Streamable HTTP + SSE)· 改动即时生效,无需重启
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="btn btn-primary" onclick="saveSettings()" style="width:100%; margin-bottom:15px">保存设置</button>
|
<button class="btn btn-primary" onclick="saveSettings()" style="width:100%; margin-bottom:15px">保存设置</button>
|
||||||
|
|
||||||
<div style="padding:15px; border:1px solid #eee; border-radius:10px">
|
<div style="padding:15px; border:1px solid #eee; border-radius:10px">
|
||||||
@@ -150,9 +168,52 @@
|
|||||||
document.getElementById('cfg_pass').value = data.webdav_password || '';
|
document.getElementById('cfg_pass').value = data.webdav_password || '';
|
||||||
document.getElementById('cfg_auto_backup').checked = data.auto_backup === 'true';
|
document.getElementById('cfg_auto_backup').checked = data.auto_backup === 'true';
|
||||||
document.getElementById('settingsModal').style.display = 'flex';
|
document.getElementById('settingsModal').style.display = 'flex';
|
||||||
|
loadMCPStatus();
|
||||||
loadCloudBackups();
|
loadCloudBackups();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadMCPStatus() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/admin/api/mcp/status');
|
||||||
|
const d = await resp.json();
|
||||||
|
const cb = document.getElementById('cfg_mcp_enabled');
|
||||||
|
const row = document.getElementById('mcpTokenRow');
|
||||||
|
cb.checked = d.enabled;
|
||||||
|
row.style.display = d.enabled ? 'block' : 'none';
|
||||||
|
document.getElementById('cfg_mcp_token').value = d.token_masked || '(未生成,点重新生成创建)';
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMCP() {
|
||||||
|
const enabled = document.getElementById('cfg_mcp_enabled').checked;
|
||||||
|
document.getElementById('mcpTokenRow').style.display = enabled ? 'block' : 'none';
|
||||||
|
fetch('/admin/api/mcp/enabled', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({enabled: enabled})
|
||||||
|
}).then(r => r.json()).then(res => {
|
||||||
|
if (!res.success) {
|
||||||
|
alert(res.message);
|
||||||
|
document.getElementById('cfg_mcp_enabled').checked = !enabled;
|
||||||
|
document.getElementById('mcpTokenRow').style.display = enabled ? 'none' : 'block';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function regenMCPToken() {
|
||||||
|
if (!confirm('确定重新生成 Token?\n\n旧 Token 将立即失效,已接入的 AI 客户端需要更新为新 Token。')) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/admin/api/mcp/token', {method: 'POST'});
|
||||||
|
const res = await resp.json();
|
||||||
|
if (res.success) {
|
||||||
|
alert('✅ 新 Token 已生成(仅此一次明文展示,请立即复制):\n\n' + res.token);
|
||||||
|
loadMCPStatus();
|
||||||
|
} else {
|
||||||
|
alert('❌ ' + res.message);
|
||||||
|
}
|
||||||
|
} catch(e) { alert('生成失败'); }
|
||||||
|
}
|
||||||
|
|
||||||
async function loadCloudBackups() {
|
async function loadCloudBackups() {
|
||||||
const listDiv = document.getElementById('backupList');
|
const listDiv = document.getElementById('backupList');
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user