;开关与 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
}
diff --git a/main.go b/main.go
index 1815de8..b68f1b1 100644
--- a/main.go
+++ b/main.go
@@ -18,9 +18,7 @@ func main() {
database.Seed()
handlers.StartHealthCheck()
handlers.StartAutoBackup()
- if err := handlers.LoadMCPConfig(); err != nil {
- log.Printf("⚠️ MCP 未启用: %v", err)
- }
+ handlers.SetupMCP()
r := gin.Default()
store := cookie.NewStore([]byte(cfg.SecretKey))
@@ -71,18 +69,18 @@ func main() {
admin.GET("/api/backup/list", handlers.ListCloudBackups)
admin.DELETE("/api/backup/delete", handlers.DeleteCloudBackup)
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)
- if handlers.MCPEnabled() {
- r.POST("/mcp", handlers.MCPAuthRequired(), handlers.MCPHandler)
- r.GET("/mcp", handlers.MCPAuthRequired(), handlers.MCPSSEHandler)
- } else {
- // 未配置 token 时仍注册端点,但返回 503 明确提示
- r.POST("/mcp", handlers.MCPHandler)
- r.GET("/mcp", handlers.MCPSSEHandler)
- }
+ // 鉴权中间件动态读数据库:后台开关/Token 改动即时生效,无需重启
+ r.POST("/mcp", handlers.MCPAuthRequired(), handlers.MCPHandler)
+ r.GET("/mcp", handlers.MCPAuthRequired(), handlers.MCPSSEHandler)
log.Printf("ToNav-go 启动在端口 %s...", cfg.Port)
r.Run(":" + cfg.Port)
diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html
index 528d693..ab7b9be 100644
--- a/templates/admin/dashboard.html
+++ b/templates/admin/dashboard.html
@@ -129,6 +129,24 @@
+
+
🤖 MCP Server
+
+
+
+
+
+
+
+
+
+
+
+ 端点: POST/GET /mcp(Streamable HTTP + SSE)· 改动即时生效,无需重启
+
+
+
+
@@ -150,9 +168,52 @@
document.getElementById('cfg_pass').value = data.webdav_password || '';
document.getElementById('cfg_auto_backup').checked = data.auto_backup === 'true';
document.getElementById('settingsModal').style.display = 'flex';
+ loadMCPStatus();
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() {
const listDiv = document.getElementById('backupList');
try {