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:
1 parent
5c17d32647
commit
0b68c8344d
19 files changed
+1989
-79
No files matched your search
@@ -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})
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user