feat(mcp): P0 只读 MCP tool runtime + opsxdb-mcp stdio

- aitools 共用 runtime:list/describe/schema/query/redis(纯只读,无写确认)
- 白名单 ~/.opsxdb/mcp.json;审计 mcp-audit.jsonl;复用 danger.go
- 桌面 connections 双写 ~/.opsxdb/connections.json(ox1. 解码)
- Cursor 配置示例与任务书验收勾选更新;写操作与 Agent UI 留 P1
This commit is contained in:
logan committed 2026-10-15 11:14:08 +08:00
1 parent 5c17d32647
commit 0b68c8344d
19 files changed
+1989 -79

No files matched your search

+87 -5
View File
@@ -11,6 +11,7 @@ import (
"github.com/gin-gonic/gin"
"opsxdb/server/internal/ai"
"opsxdb/server/internal/aitools"
"opsxdb/server/internal/db"
"opsxdb/server/internal/docs"
"opsxdb/server/internal/site"
@@ -371,6 +372,28 @@ func NewRouter(token string) *gin.Engine {
c.JSON(http.StatusOK, gin.H{"ok": true, "imported": n, "drivers": db.DriversStatus()})
})
// POST /api/db/dump 表转储 SQL:format=structure|data|structure_data(对齐 Navicat「转储 SQL 文件」)
api.POST("/db/dump", func(c *gin.Context) {
req, err := bindConn(c, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if strings.TrimSpace(req.Table) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少表名"})
return
}
ctx := c.Request.Context()
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="dump-%s-%d.sql"`, sanitizeFileToken(req.Table), time.Now().Unix()))
c.Header("Content-Type", "application/sql; charset=utf-8")
if err := db.DumpTable(ctx, req, c.Writer); err != nil {
if !c.Writer.Written() {
c.Writer.Header().Del("Content-Disposition")
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
}
})
api.POST("/ai/chat", func(c *gin.Context) {
var req ai.ChatReq
if err := c.ShouldBindJSON(&req); err != nil {
@@ -389,11 +412,14 @@ func NewRouter(token string) *gin.Engine {
api.GET("/ai/config", func(c *gin.Context) {
cfg := ai.LoadConfig()
c.JSON(http.StatusOK, gin.H{
"provider": cfg.Provider,
"baseURL": cfg.BaseURL,
"model": cfg.Model,
"hasKey": cfg.APIKey != "",
"apiKey": cfg.MaskedKey(),
"provider": cfg.Provider,
"baseURL": cfg.BaseURL,
"model": cfg.Model,
"protocol": cfg.Protocol,
"authStyle": cfg.AuthStyle,
"allowInsecureHttp": cfg.AllowInsecureHTTP,
"hasKey": cfg.APIKey != "",
"apiKey": cfg.MaskedKey(),
})
})
@@ -403,6 +429,12 @@ func NewRouter(token string) *gin.Engine {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 前端 key 留空表示「不改动」:保留已存 key,避免掩码回传语义下误清空
if req.APIKey == "" {
if old := ai.LoadConfig(); old.APIKey != "" {
req.APIKey = old.APIKey
}
}
if err := ai.SaveConfig(req); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -410,6 +442,38 @@ func NewRouter(token string) *gin.Engine {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
// 桌面连接落盘双写:供 MCP 进程读 ~/.opsxdb/connections.json(Logan.Li)
api.GET("/db/connections-store", func(c *gin.Context) {
list, err := aitools.LoadConnections()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if list == nil {
list = []aitools.SavedConnection{}
}
c.JSON(http.StatusOK, gin.H{"connections": list})
})
api.PUT("/db/connections-store", func(c *gin.Context) {
var req struct {
Connections []aitools.SavedConnection `json:"connections"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Connections == nil {
req.Connections = []aitools.SavedConnection{}
}
if err := aitools.SaveConnections(req.Connections); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(req.Connections)})
})
api.POST("/ai/generate", func(c *gin.Context) {
var req ai.ChatReq
if err := c.ShouldBindJSON(&req); err != nil {
@@ -478,6 +542,24 @@ func exportContentType(format string) (contentType, ext string) {
return "", ""
}
// sanitizeFileToken 文件名安全化:库.表等标识符去掉路径分隔与控制字符。
func sanitizeFileToken(s string) string {
var b strings.Builder
for _, r := range strings.TrimSpace(s) {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
r == '-', r == '_', r == '.', r == '@':
b.WriteRune(r)
default:
b.WriteRune('_')
}
}
if b.Len() == 0 {
return "table"
}
return b.String()
}
func health(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true, "service": "opsxdb", "version": Version})
}