feat: MCP 管理进后台 UI (v2.1) — 开关/Token 即时生效无需重启
- settings 表新增 mcp_enabled / mcp_token,鉴权中间件动态读库,后台改动即时生效(实测验证:关->503、开->200、换 token 旧401新200,均不重启) - 首次启动自动把 TONAV_MCP_TOKEN 环境变量迁移进数据库(兼容旧部署,systemd 现有 token 无感切换) - 后台新增 API:GET /admin/api/mcp/status(token 掩码)、POST /admin/api/mcp/token(重新生成+自动启用,仅一次明文展示)、POST /admin/api/mcp/enabled - 后台设置弹窗新增 MCP Server 区块:开关 checkbox、token 掩码展示、一键重新生成 - README 同步
This commit is contained in:
+105
-24
@@ -1,6 +1,8 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
cryptoRand "crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -16,35 +18,54 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ============ MCP (Model Context Protocol) v2 ============
|
||||
// ============ MCP (Model Context Protocol) v2.1 ============
|
||||
// 传输: Streamable HTTP (POST /mcp) + SSE (GET /mcp),JSON-RPC 2.0
|
||||
// 鉴权: Authorization: Bearer <TONAV_MCP_TOKEN>
|
||||
// 鉴权: Authorization: Bearer <token>;开关与 Token 存于 settings 表(后台 UI 管理),动态读取即时生效
|
||||
|
||||
type mcpConfig struct {
|
||||
Token string
|
||||
// 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
|
||||
}
|
||||
|
||||
var mcpToken string
|
||||
|
||||
// LoadMCPConfig 读取 MCP 配置(环境变量 TONAV_MCP_TOKEN)
|
||||
// v2: 未设置时返回错误,拒绝默认 token 兜底(防裸奔)
|
||||
func LoadMCPConfig() error {
|
||||
mcpToken = os.Getenv("TONAV_MCP_TOKEN")
|
||||
if mcpToken == "" {
|
||||
return fmt.Errorf("TONAV_MCP_TOKEN 未设置:MCP 端点已禁用。请设置环境变量后重启,或在 systemd 单元中配置 Environment=TONAV_MCP_TOKEN=<随机token>")
|
||||
// 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})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MCPEnabled 报告 MCP 端点是否可用
|
||||
func MCPEnabled() bool { return mcpToken != "" }
|
||||
|
||||
// MCPAuthRequired MCP Bearer Token 鉴权
|
||||
// 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")
|
||||
token := strings.TrimPrefix(auth, "Bearer ")
|
||||
if token == "" || token != mcpToken {
|
||||
tok := strings.TrimPrefix(auth, "Bearer ")
|
||||
if tok == "" || tok != token {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"jsonrpc": "2.0",
|
||||
"error": gin.H{
|
||||
@@ -653,8 +674,9 @@ func callTool(name string, args map[string]interface{}) (interface{}, error) {
|
||||
|
||||
// MCPHandler 处理 POST /mcp (Streamable HTTP)
|
||||
func MCPHandler(c *gin.Context) {
|
||||
if mcpToken == "" {
|
||||
c.JSON(http.StatusServiceUnavailable, mcpError(nil, -32000, "MCP 未启用:未配置 TONAV_MCP_TOKEN"))
|
||||
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))
|
||||
@@ -755,9 +777,67 @@ func handleMCPRequest(req jsonRPCRequest) gin.H {
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 后台 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 {
|
||||
return mcpToken
|
||||
_, token := getMCPAuthState()
|
||||
return token
|
||||
}
|
||||
|
||||
// ============ SSE 端点 (GET /mcp, v2) ============
|
||||
@@ -788,8 +868,9 @@ func notifySSE(event string, data interface{}) {
|
||||
// MCPSSEHandler 处理 GET /mcp (SSE 流式端点)
|
||||
// 客户端可收到: endpoint(初始化握手)、message(服务端主动通知)等事件
|
||||
func MCPSSEHandler(c *gin.Context) {
|
||||
if mcpToken == "" {
|
||||
c.JSON(http.StatusServiceUnavailable, mcpError(nil, -32000, "MCP 未启用:未配置 TONAV_MCP_TOKEN"))
|
||||
enabled, token := getMCPAuthState()
|
||||
if !enabled || token == "" {
|
||||
c.JSON(http.StatusServiceUnavailable, mcpError(nil, -32000, "MCP 未启用:请在后台设置中开启"))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user