feat(mcp): 设置 → MCP 子页 + 策略 API + 官网/文档接入
借鉴 DBX:安装检测(Node/npm/包/Go 二进制)、只读与 allow_dangerous、 连接白名单、多客户端配置片段;GET/PUT /api/ai/mcp-policy、/mcp-install; 文档 /docs/mcp 与落地页 AI/MCP 块;对照 docs/16 勾选设置项、列仍未完清单。
This commit is contained in:
1 parent
5416e86379
commit
1513f4bc2d
20 files changed
+1567
-98
No files matched your search
@@ -0,0 +1,271 @@
|
||||
// opsxdb-crud-accept:MySQL + Redis 增删改查冒烟(进程内 mode=read_write,不改 mcp.json)。
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"opsxdb/server/internal/aitools"
|
||||
)
|
||||
|
||||
func dump(v any) string {
|
||||
b, _ := json.MarshalIndent(v, "", " ")
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func short(v any, n int) string {
|
||||
s := dump(v)
|
||||
if len(s) > n {
|
||||
return s[:n] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
type step struct {
|
||||
Name string
|
||||
OK bool
|
||||
Note string
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
mysqlID := "accept-mysql-demo"
|
||||
redisID := "accept-redis-demo"
|
||||
marker := fmt.Sprintf("opsxdb_crud_%d", time.Now().Unix())
|
||||
email := marker + "@smoke.local"
|
||||
redisKey := "opsxdb:crud:smoke:" + marker
|
||||
|
||||
var steps []step
|
||||
record := func(name string, ok bool, note string) {
|
||||
steps = append(steps, step{Name: name, OK: ok, Note: note})
|
||||
tag := "OK"
|
||||
if !ok {
|
||||
tag = "FAIL"
|
||||
}
|
||||
fmt.Printf("[%s] %s — %s\n", tag, name, note)
|
||||
}
|
||||
|
||||
// --- A) 默认 mcp.json read_only:写必须拒 ---
|
||||
ro, err := aitools.DefaultRuntime()
|
||||
if err != nil {
|
||||
fmt.Println("FAIL: DefaultRuntime", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
_, err = ro.Call(ctx, aitools.ToolExecuteQuery, map[string]any{
|
||||
"connection_id": mysqlID, "database": "demo",
|
||||
"sql": "INSERT INTO users (name, email, status) VALUES ('crud_ro', '" + email + "', 0)",
|
||||
})
|
||||
record("MySQL INSERT under read_only", err != nil, fmt.Sprintf("err=%v", err))
|
||||
|
||||
_, err = ro.Call(ctx, aitools.ToolExecuteRedisCommand, map[string]any{
|
||||
"connection_id": redisID, "command": "SET " + redisKey + " v1",
|
||||
})
|
||||
record("Redis SET under read_only", err != nil, fmt.Sprintf("err=%v", err))
|
||||
|
||||
// --- B) 进程内 read_write Runtime ---
|
||||
rw := aitools.NewRuntime(aitools.Policy{
|
||||
AllowedConnectionIDs: []string{mysqlID, redisID},
|
||||
Mode: aitools.ModeReadWrite,
|
||||
AllowDangerous: true, // 验收 DROP 确认路径;生产默认 false
|
||||
DefaultLimit: 100,
|
||||
MaxLimit: 500,
|
||||
})
|
||||
rw.Source = "crud-accept"
|
||||
|
||||
// B1 无确认 → needConfirm
|
||||
out, err := rw.Call(ctx, aitools.ToolExecuteQuery, map[string]any{
|
||||
"connection_id": mysqlID, "database": "demo",
|
||||
"sql": "INSERT INTO users (name, email, status) VALUES ('crud_pending', '" + email + "', 0)",
|
||||
})
|
||||
need := false
|
||||
bind := ""
|
||||
if err == nil {
|
||||
if m, ok := out.(map[string]any); ok {
|
||||
need, _ = m["needConfirm"].(bool)
|
||||
bind, _ = m["confirmBind"].(string)
|
||||
}
|
||||
}
|
||||
record("MySQL INSERT no confirm → needConfirm", err == nil && need && bind != "", fmt.Sprintf("needConfirm=%v bind=%s… out=%s", need, trunc(bind, 12), short(out, 220)))
|
||||
|
||||
// B1b allow_dangerous=false 时 DROP 即便确认也拒
|
||||
rwSafe := aitools.NewRuntime(aitools.Policy{
|
||||
AllowedConnectionIDs: []string{mysqlID, redisID},
|
||||
Mode: aitools.ModeReadWrite,
|
||||
AllowDangerous: false,
|
||||
})
|
||||
rwSafe.AllowConfirmedWrite = true
|
||||
_, err = rwSafe.Call(ctx, aitools.ToolExecuteQuery, map[string]any{
|
||||
"connection_id": mysqlID, "database": "demo",
|
||||
"sql": "DROP TABLE IF EXISTS __opsxdb_crud_deny_ddl__", "confirm_danger": true,
|
||||
})
|
||||
record("MySQL DROP denied without allow_dangerous", err != nil && strings.Contains(fmt.Sprint(err), "dangerous sql disabled"), fmt.Sprintf("err=%v", err))
|
||||
|
||||
// B2 DROP 无确认 → needConfirm(不真执行)
|
||||
out, err = rw.Call(ctx, aitools.ToolExecuteQuery, map[string]any{
|
||||
"connection_id": mysqlID, "database": "demo",
|
||||
"sql": "DROP TABLE IF EXISTS __opsxdb_crud_should_deny__",
|
||||
})
|
||||
need = false
|
||||
if err == nil {
|
||||
if m, ok := out.(map[string]any); ok {
|
||||
need, _ = m["needConfirm"].(bool)
|
||||
}
|
||||
}
|
||||
record("MySQL DROP no confirm → needConfirm", err == nil && need, short(out, 200))
|
||||
|
||||
// B3 SELECT 基线
|
||||
out, err = rw.Call(ctx, aitools.ToolExecuteQuery, map[string]any{
|
||||
"connection_id": mysqlID, "database": "demo",
|
||||
"sql": "SELECT id, name, email, status FROM users ORDER BY id DESC LIMIT 5", "limit": 5,
|
||||
})
|
||||
record("MySQL SELECT baseline", err == nil, short(out, 400))
|
||||
|
||||
// B4 describe users
|
||||
out, err = rw.Call(ctx, aitools.ToolDescribeTable, map[string]any{
|
||||
"connection_id": mysqlID, "database": "demo", "table": "users",
|
||||
})
|
||||
record("MySQL describe users", err == nil, short(out, 350))
|
||||
|
||||
// B5 INSERT + confirm
|
||||
insSQL := "INSERT INTO users (name, email, status) VALUES ('crud_smoke', '" + email + "', 1)"
|
||||
out, err = rw.Call(ctx, aitools.ToolExecuteQuery, map[string]any{
|
||||
"connection_id": mysqlID, "database": "demo",
|
||||
"sql": insSQL, "confirm_danger": true,
|
||||
})
|
||||
record("MySQL INSERT confirm", err == nil, short(out, 300))
|
||||
|
||||
// B6 SELECT 确认插入
|
||||
out, err = rw.Call(ctx, aitools.ToolExecuteQuery, map[string]any{
|
||||
"connection_id": mysqlID, "database": "demo",
|
||||
"sql": "SELECT id, name, email, status FROM users WHERE email = '" + email + "'",
|
||||
})
|
||||
var insertedID any
|
||||
rowCount := 0
|
||||
if err == nil {
|
||||
s := dump(out)
|
||||
if strings.Contains(s, `"rowCount": 1`) || strings.Contains(s, `"rowCount":1`) {
|
||||
rowCount = 1
|
||||
}
|
||||
if strings.Contains(s, "crud_smoke") && strings.Contains(s, email) {
|
||||
insertedID = "found"
|
||||
}
|
||||
}
|
||||
record("MySQL SELECT after INSERT", err == nil && rowCount >= 1 && insertedID != nil,
|
||||
fmt.Sprintf("rowCount=%d id=%v %s", rowCount, insertedID, short(out, 350)))
|
||||
|
||||
// B7 UPDATE + confirm
|
||||
updSQL := fmt.Sprintf("UPDATE users SET name = 'crud_updated', status = 0 WHERE email = '%s'", email)
|
||||
out, err = rw.Call(ctx, aitools.ToolExecuteQuery, map[string]any{
|
||||
"connection_id": mysqlID, "database": "demo",
|
||||
"sql": updSQL, "confirm_danger": true,
|
||||
})
|
||||
record("MySQL UPDATE confirm", err == nil, short(out, 250))
|
||||
|
||||
out, err = rw.Call(ctx, aitools.ToolExecuteQuery, map[string]any{
|
||||
"connection_id": mysqlID, "database": "demo",
|
||||
"sql": "SELECT id, name, email, status FROM users WHERE email = '" + email + "'",
|
||||
})
|
||||
nameOK := false
|
||||
if err == nil {
|
||||
s := dump(out)
|
||||
nameOK = strings.Contains(s, "crud_updated")
|
||||
}
|
||||
record("MySQL SELECT after UPDATE", err == nil && nameOK, short(out, 350))
|
||||
|
||||
// B8 DELETE + confirm(清理)
|
||||
delSQL := fmt.Sprintf("DELETE FROM users WHERE email = '%s'", email)
|
||||
out, err = rw.Call(ctx, aitools.ToolExecuteQuery, map[string]any{
|
||||
"connection_id": mysqlID, "database": "demo",
|
||||
"sql": delSQL, "confirm_danger": true,
|
||||
})
|
||||
record("MySQL DELETE confirm (cleanup)", err == nil, short(out, 250))
|
||||
|
||||
out, err = rw.Call(ctx, aitools.ToolExecuteQuery, map[string]any{
|
||||
"connection_id": mysqlID, "database": "demo",
|
||||
"sql": "SELECT id FROM users WHERE email = '" + email + "'",
|
||||
})
|
||||
gone := false
|
||||
if err == nil {
|
||||
if m, ok := out.(map[string]any); ok {
|
||||
switch rc := m["rowCount"].(type) {
|
||||
case int:
|
||||
gone = rc == 0
|
||||
case float64:
|
||||
gone = int(rc) == 0
|
||||
}
|
||||
}
|
||||
}
|
||||
record("MySQL SELECT after DELETE (expect 0)", err == nil && gone, short(out, 200))
|
||||
|
||||
// --- C) Redis ---
|
||||
out, err = rw.Call(ctx, aitools.ToolExecuteRedisCommand, map[string]any{
|
||||
"connection_id": redisID, "command": "SET " + redisKey + " hello-opsxdb",
|
||||
})
|
||||
need = false
|
||||
if err == nil {
|
||||
if m, ok := out.(map[string]any); ok {
|
||||
need, _ = m["needConfirm"].(bool)
|
||||
}
|
||||
}
|
||||
record("Redis SET no confirm → needConfirm", err == nil && need, short(out, 220))
|
||||
|
||||
out, err = rw.Call(ctx, aitools.ToolExecuteRedisCommand, map[string]any{
|
||||
"connection_id": redisID, "command": "SET " + redisKey + " hello-opsxdb", "confirm_danger": true,
|
||||
})
|
||||
record("Redis SET confirm", err == nil, short(out, 220))
|
||||
|
||||
out, err = rw.Call(ctx, aitools.ToolExecuteRedisCommand, map[string]any{
|
||||
"connection_id": redisID, "command": "GET " + redisKey,
|
||||
})
|
||||
got := strings.Contains(dump(out), "hello-opsxdb")
|
||||
record("Redis GET", err == nil && got, short(out, 220))
|
||||
|
||||
out, err = rw.Call(ctx, aitools.ToolExecuteRedisCommand, map[string]any{
|
||||
"connection_id": redisID, "command": "DEL " + redisKey, "confirm_danger": true,
|
||||
})
|
||||
record("Redis DEL confirm (cleanup)", err == nil, short(out, 220))
|
||||
|
||||
out, err = rw.Call(ctx, aitools.ToolExecuteRedisCommand, map[string]any{
|
||||
"connection_id": redisID, "command": "GET " + redisKey,
|
||||
})
|
||||
record("Redis GET after DEL", err == nil, short(out, 200))
|
||||
|
||||
_, err = rw.Call(ctx, aitools.ToolExecuteRedisCommand, map[string]any{
|
||||
"connection_id": redisID, "command": "FLUSHDB", "confirm_danger": true,
|
||||
})
|
||||
record("Redis FLUSHDB always deny (even confirmed)", err != nil, fmt.Sprintf("err=%v", err))
|
||||
|
||||
_, err = rw.Call(ctx, aitools.ToolExecuteRedisCommand, map[string]any{
|
||||
"connection_id": redisID, "command": "KEYS *", "confirm_danger": true,
|
||||
})
|
||||
record("Redis KEYS always deny", err != nil, fmt.Sprintf("err=%v", err))
|
||||
|
||||
// --- D) incomplete / max_steps 若可(不依赖 LLM,仅看 harness 配置字段)---
|
||||
// 用极小 MaxRounds 的 Agent 需要 LLM;这里只验证 ConfirmBindHash 与 outcome 字段在包内可用。
|
||||
bind2 := aitools.ConfirmBindHash(mysqlID, insSQL)
|
||||
record("ConfirmBindHash non-empty", bind2 != "", "bind="+trunc(bind2, 16))
|
||||
|
||||
failN := 0
|
||||
for _, s := range steps {
|
||||
if !s.OK {
|
||||
failN++
|
||||
}
|
||||
}
|
||||
fmt.Println("---")
|
||||
fmt.Printf("TOTAL %d steps, FAIL=%d marker=%s\n", len(steps), failN, marker)
|
||||
if failN > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("ALL CRUD ACCEPTANCE PASSED")
|
||||
}
|
||||
|
||||
func trunc(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package aitools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const npmPackageName = "@rlwj-lab/opsxdb-mcp"
|
||||
|
||||
// MCPInstallInfo 设置 → MCP 页的安装/环境检测结果。
|
||||
type MCPInstallInfo struct {
|
||||
Status string `json:"status"` // ready | not_installed | partial
|
||||
Installed bool `json:"installed"`
|
||||
CurrentVersion string `json:"currentVersion,omitempty"`
|
||||
LatestVersion string `json:"latestVersion,omitempty"`
|
||||
NodeVersion string `json:"nodeVersion,omitempty"`
|
||||
NpmAvailable bool `json:"npmAvailable"`
|
||||
NpmVersion string `json:"npmVersion,omitempty"`
|
||||
NpmPackageInstalled bool `json:"npmPackageInstalled"`
|
||||
NpmPackageVersion string `json:"npmPackageVersion,omitempty"`
|
||||
GoBinaryFound bool `json:"goBinaryFound"`
|
||||
GoBinaryPath string `json:"goBinaryPath,omitempty"`
|
||||
InstallCommand string `json:"installCommand"`
|
||||
NpxCommand string `json:"npxCommand"`
|
||||
PolicyPath string `json:"policyPath,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
// ProbeMCPInstall 检测 Node/npm、全局 npm 包、Go 二进制与 registry 最新版。
|
||||
func ProbeMCPInstall() MCPInstallInfo {
|
||||
info := MCPInstallInfo{
|
||||
Status: "not_installed",
|
||||
InstallCommand: "npm install -g " + npmPackageName + "@latest",
|
||||
NpxCommand: "npx -y " + npmPackageName,
|
||||
}
|
||||
if p, err := PolicyPath(); err == nil {
|
||||
info.PolicyPath = p
|
||||
}
|
||||
|
||||
node, nodeVer := findToolVersion("node", "--version")
|
||||
npm, npmVer := findToolVersion("npm", "--version")
|
||||
info.NodeVersion = strings.TrimPrefix(strings.TrimSpace(nodeVer), "v")
|
||||
if node != "" && info.NodeVersion != "" {
|
||||
info.NodeVersion = "v" + strings.TrimPrefix(info.NodeVersion, "v")
|
||||
}
|
||||
info.NpmAvailable = npm != ""
|
||||
info.NpmVersion = strings.TrimSpace(npmVer)
|
||||
|
||||
if bin := resolveGoMCPBinary(); bin != "" {
|
||||
info.GoBinaryFound = true
|
||||
info.GoBinaryPath = bin
|
||||
}
|
||||
|
||||
if npm != "" {
|
||||
ver := npmGlobalPackageVersion(npm, npmPackageName)
|
||||
if ver != "" {
|
||||
info.NpmPackageInstalled = true
|
||||
info.NpmPackageVersion = ver
|
||||
info.CurrentVersion = ver
|
||||
}
|
||||
}
|
||||
if info.CurrentVersion == "" && info.GoBinaryFound {
|
||||
info.CurrentVersion = "native"
|
||||
}
|
||||
|
||||
info.LatestVersion = fetchLatestNpmVersion(npmPackageName)
|
||||
|
||||
switch {
|
||||
case info.GoBinaryFound && (info.NpmPackageInstalled || info.NpmAvailable):
|
||||
info.Status = "ready"
|
||||
info.Installed = true
|
||||
if !info.NpmPackageInstalled {
|
||||
info.Hint = "已找到 Go 二进制;建议再 npm i -g,或用 npx + OPSXDB_MCP_BIN。"
|
||||
}
|
||||
case info.GoBinaryFound:
|
||||
info.Status = "partial"
|
||||
info.Installed = true
|
||||
info.Hint = "已找到 Go 二进制,但未检测到 Node/npm。客户端可用绝对路径启动二进制。"
|
||||
case info.NpmPackageInstalled:
|
||||
info.Status = "partial"
|
||||
info.Installed = false
|
||||
info.Hint = "npm 包已装,但未找到 Go 二进制。请 go build 后设置 OPSXDB_MCP_BIN。"
|
||||
case info.NpmAvailable:
|
||||
info.Status = "not_installed"
|
||||
info.Hint = "检测到 Node/npm,尚未安装 MCP 包或 Go 二进制。"
|
||||
default:
|
||||
info.Status = "not_installed"
|
||||
info.Hint = "未检测到 Node.js。请安装 Node 18+,或直接用 Go 二进制绝对路径配置客户端。"
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func findToolVersion(name string, versionArg string) (bin, version string) {
|
||||
bin = lookPathAugmented(name)
|
||||
if bin == "" {
|
||||
return "", ""
|
||||
}
|
||||
out, err := exec.Command(bin, versionArg).CombinedOutput()
|
||||
if err != nil {
|
||||
return bin, ""
|
||||
}
|
||||
return bin, strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func lookPathAugmented(name string) string {
|
||||
if p, err := exec.LookPath(name); err == nil {
|
||||
return p
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
var extras []string
|
||||
if home != "" {
|
||||
nvm := filepath.Join(home, ".nvm", "versions", "node")
|
||||
if ents, err := os.ReadDir(nvm); err == nil {
|
||||
for _, e := range ents {
|
||||
if e.IsDir() {
|
||||
extras = append(extras, filepath.Join(nvm, e.Name(), "bin"))
|
||||
}
|
||||
}
|
||||
}
|
||||
extras = append(extras,
|
||||
filepath.Join(home, ".local", "bin"),
|
||||
filepath.Join(home, "bin"),
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
)
|
||||
}
|
||||
pathEnv := os.Getenv("PATH")
|
||||
aug := strings.Join(extras, string(os.PathListSeparator))
|
||||
if pathEnv != "" {
|
||||
aug = aug + string(os.PathListSeparator) + pathEnv
|
||||
}
|
||||
cmd := exec.Command(name, "--version")
|
||||
cmd.Env = append(os.Environ(), "PATH="+aug)
|
||||
// Prefer LookPath with augmented PATH
|
||||
for _, dir := range extras {
|
||||
cand := filepath.Join(dir, name)
|
||||
if runtime.GOOS == "windows" {
|
||||
cand += ".exe"
|
||||
}
|
||||
if st, err := os.Stat(cand); err == nil && !st.IsDir() {
|
||||
return cand
|
||||
}
|
||||
}
|
||||
if p, err := exec.LookPath(name); err == nil {
|
||||
return p
|
||||
}
|
||||
_ = cmd
|
||||
return ""
|
||||
}
|
||||
|
||||
func npmGlobalPackageVersion(npmBin, pkg string) string {
|
||||
out, err := exec.Command(npmBin, "list", "-g", pkg, "--depth=0", "--json").CombinedOutput()
|
||||
if err != nil && len(out) == 0 {
|
||||
return ""
|
||||
}
|
||||
var raw struct {
|
||||
Dependencies map[string]struct {
|
||||
Version string `json:"version"`
|
||||
} `json:"dependencies"`
|
||||
}
|
||||
if json.Unmarshal(out, &raw) != nil {
|
||||
return ""
|
||||
}
|
||||
if d, ok := raw.Dependencies[pkg]; ok {
|
||||
return d.Version
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fetchLatestNpmVersion(pkg string) string {
|
||||
client := &http.Client{Timeout: 4 * time.Second}
|
||||
url := "https://registry.npmjs.org/" + pkg + "/latest"
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != 200 {
|
||||
return ""
|
||||
}
|
||||
var raw struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if json.NewDecoder(res.Body).Decode(&raw) != nil {
|
||||
return ""
|
||||
}
|
||||
return raw.Version
|
||||
}
|
||||
|
||||
func resolveGoMCPBinary() string {
|
||||
if env := strings.TrimSpace(os.Getenv("OPSXDB_MCP_BIN")); env != "" {
|
||||
if isLikelyNativeBinary(env) {
|
||||
return env
|
||||
}
|
||||
}
|
||||
if p, err := exec.LookPath("opsxdb-mcp"); err == nil && isLikelyNativeBinary(p) {
|
||||
return p
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
cands := []string{}
|
||||
if home != "" {
|
||||
cands = append(cands,
|
||||
filepath.Join(home, "devops", "opsxdb", "bin", "opsxdb-mcp"),
|
||||
filepath.Join(home, ".opsxdb", "bin", "opsxdb-mcp"),
|
||||
filepath.Join(home, ".local", "bin", "opsxdb-mcp"),
|
||||
)
|
||||
}
|
||||
// Relative to running server binary (src-tauri/binaries or server/)
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
dir := filepath.Dir(exe)
|
||||
cands = append(cands,
|
||||
filepath.Join(dir, "opsxdb-mcp"),
|
||||
filepath.Join(dir, "..", "opsxdb-mcp"),
|
||||
filepath.Join(dir, "..", "..", "bin", "opsxdb-mcp"),
|
||||
filepath.Join(dir, "..", "..", "..", "bin", "opsxdb-mcp"),
|
||||
)
|
||||
}
|
||||
cands = append(cands, "/usr/local/bin/opsxdb-mcp", "/opt/homebrew/bin/opsxdb-mcp")
|
||||
for _, c := range cands {
|
||||
if isLikelyNativeBinary(c) {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isLikelyNativeBinary(path string) bool {
|
||||
st, err := os.Stat(path)
|
||||
if err != nil || st.IsDir() {
|
||||
return false
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
buf := make([]byte, 8)
|
||||
n, _ := f.Read(buf)
|
||||
if n < 2 {
|
||||
return false
|
||||
}
|
||||
// shebang → script
|
||||
if buf[0] == '#' && buf[1] == '!' {
|
||||
return false
|
||||
}
|
||||
if n >= 4 && buf[0] == 0x7f && buf[1] == 'E' && buf[2] == 'L' && buf[3] == 'F' {
|
||||
return true
|
||||
}
|
||||
if buf[0] == 'M' && buf[1] == 'Z' {
|
||||
return true
|
||||
}
|
||||
if n >= 4 {
|
||||
be := uint32(buf[0])<<24 | uint32(buf[1])<<16 | uint32(buf[2])<<8 | uint32(buf[3])
|
||||
le := uint32(buf[3])<<24 | uint32(buf[2])<<16 | uint32(buf[1])<<8 | uint32(buf[0])
|
||||
macho := map[uint32]bool{
|
||||
0xfeedfacf: true, 0xcffaedfe: true, 0xfeedface: true, 0xcefaedfe: true,
|
||||
0xcafebabe: true, 0xbebafeca: true, 0xcafebabf: true, 0xbfbafeca: true,
|
||||
}
|
||||
if macho[be] || macho[le] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package aitools
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestProbeMCPInstallSmoke(t *testing.T) {
|
||||
info := ProbeMCPInstall()
|
||||
if info.InstallCommand == "" {
|
||||
t.Fatal("empty install command")
|
||||
}
|
||||
if info.Status == "" {
|
||||
t.Fatal("empty status")
|
||||
}
|
||||
t.Logf("status=%s node=%s npm=%v go=%v path=%s latest=%s",
|
||||
info.Status, info.NodeVersion, info.NpmAvailable, info.GoBinaryFound, info.GoBinaryPath, info.LatestVersion)
|
||||
}
|
||||
@@ -18,8 +18,11 @@ const (
|
||||
type Policy struct {
|
||||
AllowedConnectionIDs []string `json:"allowed_connection_ids"`
|
||||
Mode string `json:"mode"` // read_only | read_write
|
||||
DefaultLimit int `json:"default_limit,omitempty"`
|
||||
MaxLimit int `json:"max_limit,omitempty"`
|
||||
// AllowDangerous 为 true 时,在 mode=read_write 且已确认后允许 DROP/TRUNCATE/ALTER 等危险 SQL。
|
||||
// 默认 false:普通 INSERT/UPDATE/DELETE(有 WHERE)仍可写,DDL/无 WHERE 等仍拒。
|
||||
AllowDangerous bool `json:"allow_dangerous,omitempty"`
|
||||
DefaultLimit int `json:"default_limit,omitempty"`
|
||||
MaxLimit int `json:"max_limit,omitempty"`
|
||||
}
|
||||
|
||||
func DefaultPolicy() Policy {
|
||||
@@ -114,3 +117,62 @@ func (p Policy) ClampLimit(n int) int {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// SavePolicy 原子写入 ~/.opsxdb/mcp.json(先写临时文件再 rename)。
|
||||
func SavePolicy(p Policy) error {
|
||||
p.Mode = strings.ToLower(strings.TrimSpace(p.Mode))
|
||||
if p.Mode != ModeReadWrite {
|
||||
p.Mode = ModeReadOnly
|
||||
}
|
||||
if p.DefaultLimit <= 0 {
|
||||
p.DefaultLimit = 100
|
||||
}
|
||||
if p.MaxLimit <= 0 {
|
||||
p.MaxLimit = 500
|
||||
}
|
||||
if p.DefaultLimit > p.MaxLimit {
|
||||
p.DefaultLimit = p.MaxLimit
|
||||
}
|
||||
if p.AllowedConnectionIDs == nil {
|
||||
p.AllowedConnectionIDs = []string{}
|
||||
}
|
||||
// 去空白、去重保序
|
||||
seen := map[string]struct{}{}
|
||||
clean := make([]string, 0, len(p.AllowedConnectionIDs))
|
||||
for _, id := range p.AllowedConnectionIDs {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
clean = append(clean, id)
|
||||
}
|
||||
p.AllowedConnectionIDs = clean
|
||||
|
||||
path, err := PolicyPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := json.MarshalIndent(p, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b = append(b, '\n')
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// AllowsDangerousDDL 危险 SQL(DROP/TRUNCATE/…)是否在确认后放行。
|
||||
func (p Policy) AllowsDangerousDDL() bool {
|
||||
return p.AllowDangerous && p.WritesEnabled()
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package aitools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSaveLoadPolicyRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("HOME", dir)
|
||||
// PolicyPath uses UserHomeDir → ~/.opsxdb/mcp.json
|
||||
p := Policy{
|
||||
AllowedConnectionIDs: []string{" a ", "b", "a", ""},
|
||||
Mode: "READ_WRITE",
|
||||
AllowDangerous: true,
|
||||
DefaultLimit: 50,
|
||||
MaxLimit: 200,
|
||||
}
|
||||
if err := SavePolicy(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, ".opsxdb", "mcp.json")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := LoadPolicy()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Mode != ModeReadWrite {
|
||||
t.Fatalf("mode=%s", got.Mode)
|
||||
}
|
||||
if !got.AllowDangerous {
|
||||
t.Fatal("want allow_dangerous")
|
||||
}
|
||||
if len(got.AllowedConnectionIDs) != 2 || got.AllowedConnectionIDs[0] != "a" || got.AllowedConnectionIDs[1] != "b" {
|
||||
t.Fatalf("ids=%v", got.AllowedConnectionIDs)
|
||||
}
|
||||
if got.DefaultLimit != 50 || got.MaxLimit != 200 {
|
||||
t.Fatalf("limits %d/%d", got.DefaultLimit, got.MaxLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDangerousSQLRequiresAllowFlag(t *testing.T) {
|
||||
rt := NewRuntime(Policy{
|
||||
AllowedConnectionIDs: []string{"c1"},
|
||||
Mode: ModeReadWrite,
|
||||
AllowDangerous: false,
|
||||
})
|
||||
rt.FindConn = func(id string) (*SavedConnection, error) {
|
||||
return &SavedConnection{ID: id, Name: "c1", Driver: "mysql", Host: "127.0.0.1", Port: 3306}, nil
|
||||
}
|
||||
rt.Audit = func(AuditEntry) {}
|
||||
rt.AllowConfirmedWrite = true
|
||||
_, err := rt.Call(context.Background(), ToolExecuteQuery, map[string]any{
|
||||
"connection_id": "c1",
|
||||
"sql": "DROP TABLE users",
|
||||
"confirm_danger": true,
|
||||
})
|
||||
if err == nil || !contains(err.Error(), "dangerous sql disabled") {
|
||||
t.Fatalf("want dangerous disabled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool { return strings.Contains(s, sub) }
|
||||
@@ -401,6 +401,10 @@ func (r *Runtime) executeQuery(ctx context.Context, args map[string]any) (any, e
|
||||
if !r.Policy.WritesEnabled() {
|
||||
return fail(readReason)
|
||||
}
|
||||
// 危险 DDL(DROP/TRUNCATE/…)另需 allow_dangerous;普通 INSERT/UPDATE/DELETE 不受此开关限制。
|
||||
if hits := db.CheckDangerousSQL("", sqlText); len(hits) > 0 && !r.Policy.AllowDangerous {
|
||||
return fail("dangerous sql disabled: enable allow_dangerous in Settings → MCP (or mcp.json); hits: " + strings.Join(hits, "; "))
|
||||
}
|
||||
// P1:可写已开启,但无确认令牌则返回 needConfirm(永不静默写)。
|
||||
if !confirmed {
|
||||
r.audit(AuditEntry{Tool: ToolExecuteQuery, ConnectionID: id, OK: false, Error: "needConfirm", Summary: truncate(sqlText, 80)})
|
||||
|
||||
@@ -287,7 +287,7 @@ func TestExecuteQueryRejectConfirmWhenAgentFlag(t *testing.T) {
|
||||
}
|
||||
rt.Audit = func(AuditEntry) {}
|
||||
out, err := rt.Call(context.Background(), ToolExecuteQuery, map[string]any{
|
||||
"connection_id": "c1", "sql": "UPDATE t SET a=1", "confirm_danger": true,
|
||||
"connection_id": "c1", "sql": "UPDATE t SET a=1 WHERE id=1", "confirm_danger": true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -15,6 +15,7 @@ type MCPStatus struct {
|
||||
Mode string `json:"mode"`
|
||||
WhitelistCount int `json:"whitelistCount"`
|
||||
WritesEnabled bool `json:"writesEnabled"`
|
||||
AllowDangerous bool `json:"allowDangerous"`
|
||||
Active bool `json:"active"` // 近 5 分钟有 source=mcp 审计
|
||||
Recent []AuditEntry `json:"recent,omitempty"`
|
||||
PolicyPath string `json:"policyPath,omitempty"`
|
||||
@@ -37,6 +38,7 @@ func GetMCPStatus() MCPStatus {
|
||||
}
|
||||
st.WhitelistCount = len(p.AllowedConnectionIDs)
|
||||
st.WritesEnabled = p.WritesEnabled()
|
||||
st.AllowDangerous = p.AllowDangerous
|
||||
st.Configured = st.Configured || st.WhitelistCount > 0
|
||||
}
|
||||
if ap, err := AuditPath(); err == nil {
|
||||
|
||||
@@ -30,10 +30,14 @@ Agent 支持 openai-chat(DeepSeek 等)、Anthropic messages、OpenAI respons
|
||||
把同一套工具暴露给 Cursor / Claude / Codex:
|
||||
|
||||
```bash
|
||||
npx @rlwj-lab/opsxdb-mcp
|
||||
npm install -g @rlwj-lab/opsxdb-mcp@latest
|
||||
# 另需 Go 二进制,见 MCP 接入文档
|
||||
npx -y @rlwj-lab/opsxdb-mcp
|
||||
```
|
||||
|
||||
配置示例见仓库 `docs/mcp-cursor.example.json`。白名单写在 `~/.opsxdb/mcp.json` 的 `allowed_connection_ids`;空白名单拒绝全部连接。审计:`~/.opsxdb/logs/mcp-audit.jsonl`。
|
||||
在桌面 **设置 → MCP** 可检测安装、切换只读/危险 SQL、勾选连接白名单,并复制 Cursor / Claude Code / VS Code 等配置片段。完整说明见 [MCP 接入](/docs/mcp)。
|
||||
|
||||
白名单写在 `~/.opsxdb/mcp.json` 的 `allowed_connection_ids`;空白名单拒绝全部连接。审计:`~/.opsxdb/logs/mcp-audit.jsonl`。
|
||||
|
||||
侧栏标题旁仅在外部 MCP **近期有调用**时显示绿色「MCP」点;悬停/点击可看最近摘要。
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# MCP 接入
|
||||
|
||||
把 Cursor、Claude Code、VS Code 等 AI 客户端接到 OpsxDB,即可用自然语言查看结构、查询白名单内的库,并与侧栏 Agent **共用同一套工具与安全策略**。
|
||||
|
||||
## 什么是 MCP?
|
||||
|
||||
MCP(Model Context Protocol)让 AI 客户端调用本机工具。OpsxDB MCP 读取桌面已保存的连接(`~/.opsxdb/connections.json`),权限由 `~/.opsxdb/mcp.json` 统一约束。
|
||||
|
||||
```
|
||||
AI 助手 → opsxdb-mcp (stdio) → aitools Runtime → 你的数据库
|
||||
↘ 审计 ~/.opsxdb/logs/mcp-audit.jsonl
|
||||
```
|
||||
|
||||
## 安装
|
||||
|
||||
### 1. Node 启动器(npm)
|
||||
|
||||
```bash
|
||||
npm install -g @rlwj-lab/opsxdb-mcp@latest
|
||||
# 或一次性
|
||||
npx -y @rlwj-lab/opsxdb-mcp
|
||||
```
|
||||
|
||||
### 2. Go 二进制(必需)
|
||||
|
||||
npm 包只是启动器,真正的 MCP 服务是 Go 二进制:
|
||||
|
||||
```bash
|
||||
cd server && go build -o ../bin/opsxdb-mcp ./cmd/opsxdb-mcp
|
||||
export OPSXDB_MCP_BIN=/绝对路径/opsxdb/bin/opsxdb-mcp
|
||||
```
|
||||
|
||||
也可把该二进制放到 PATH(需排在 npm shim 之前)。
|
||||
|
||||
### 3. 桌面设置
|
||||
|
||||
打开 **设置 → MCP**:
|
||||
|
||||
- 查看 Node / npm / 包版本 / Go 二进制是否就绪
|
||||
- 复制安装命令与各客户端配置片段
|
||||
- 配置 **只读模式**、**允许危险 SQL**、**连接白名单**
|
||||
|
||||
## 配置 AI 客户端
|
||||
|
||||
以 Cursor 为例(`~/.cursor/mcp.json` 或项目 `.cursor/mcp.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"opsxdb": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@rlwj-lab/opsxdb-mcp"],
|
||||
"env": {
|
||||
"OPSXDB_MCP_BIN": "/绝对路径/opsxdb/bin/opsxdb-mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Claude Code 使用项目根目录 `.mcp.json`(结构相同)。VS Code / Windsurf / Codex / Claude Desktop 的片段可在 **设置 → MCP** 中按 Tab 复制。
|
||||
|
||||
> GUI 客户端不一定继承 shell 的 PATH;请使用 `OPSXDB_MCP_BIN` 绝对路径,不要只写 `opsxdb-mcp` 或 `~`。
|
||||
|
||||
## 安全策略
|
||||
|
||||
| 项 | 说明 |
|
||||
| --- | --- |
|
||||
| 默认只读 | `mode=read_only`:仅 SELECT/SHOW/EXPLAIN 与 Redis 读命令 |
|
||||
| 可写 | 设置中关闭「只读模式」→ `mode=read_write`;每次写仍须确认 |
|
||||
| 危险 SQL | 「允许危险 SQL」对应 `allow_dangerous`;DROP/TRUNCATE/ALTER/无 WHERE 更新删除等 |
|
||||
| 连接白名单 | `allowed_connection_ids`;**空=拒绝全部** |
|
||||
| 审计 | `~/.opsxdb/logs/mcp-audit.jsonl`(含 tool、connection_id、run_id) |
|
||||
|
||||
外部客户端若在 `read_write` 下自行传 `confirm_danger`,视为操作者授权;桌面侧栏 Agent **不会**让模型静默带确认令牌。
|
||||
|
||||
## 工具列表
|
||||
|
||||
| 工具 | 说明 |
|
||||
| --- | --- |
|
||||
| `opsxdb_list_connections` | 白名单内连接 |
|
||||
| `opsxdb_list_databases` | 库列表 |
|
||||
| `opsxdb_list_tables` | 表/视图 |
|
||||
| `opsxdb_describe_table` | 列定义 |
|
||||
| `opsxdb_get_schema_context` | 紧凑 schema 文本 |
|
||||
| `opsxdb_execute_query` | SQL(默认只读,行数上限) |
|
||||
| `opsxdb_execute_redis_command` | Redis(默认只读;FLUSH*/KEYS 永拒) |
|
||||
|
||||
## 侧栏 Ask | Agent
|
||||
|
||||
- **Ask**:生成/解释/优化,不调库
|
||||
- **Agent**:多轮调用与 MCP 同源工具;支持 SSE 步骤时间线、「插入编辑器」「在结果区打开」
|
||||
|
||||
详见 [AI 助手](/docs/ai)。
|
||||
|
||||
## 开始使用
|
||||
|
||||
在 AI 客户端中试:
|
||||
|
||||
- 「列出我的数据库连接」
|
||||
- 「demo 库有哪些表」
|
||||
- 「查看 users 表结构并查前 5 行」
|
||||
|
||||
## 系统要求
|
||||
|
||||
- Node.js 18+(若用 npm/npx 启动器)
|
||||
- 已安装 OpsxDB 桌面端并至少保存一个连接
|
||||
- 已构建 `opsxdb-mcp` Go 二进制
|
||||
- 在设置 → MCP 勾选连接白名单
|
||||
@@ -4,37 +4,98 @@
|
||||
{
|
||||
"name": "开始使用",
|
||||
"pages": [
|
||||
{ "file": "01-intro.md", "slug": "intro", "title": "产品介绍", "updated": "2026-09-20" },
|
||||
{ "file": "02-quickstart.md", "slug": "quickstart", "title": "快速开始", "updated": "2026-09-20" },
|
||||
{ "file": "03-interface.md", "slug": "interface", "title": "界面导览", "updated": "2026-09-20" }
|
||||
{
|
||||
"file": "01-intro.md",
|
||||
"slug": "intro",
|
||||
"title": "产品介绍",
|
||||
"updated": "2026-09-20"
|
||||
},
|
||||
{
|
||||
"file": "02-quickstart.md",
|
||||
"slug": "quickstart",
|
||||
"title": "快速开始",
|
||||
"updated": "2026-09-20"
|
||||
},
|
||||
{
|
||||
"file": "03-interface.md",
|
||||
"slug": "interface",
|
||||
"title": "界面导览",
|
||||
"updated": "2026-09-20"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "连接与驱动",
|
||||
"pages": [
|
||||
{ "file": "04-connect.md", "slug": "connect", "title": "新建连接", "updated": "2026-09-20" },
|
||||
{ "file": "05-drivers.md", "slug": "drivers", "title": "驱动支持", "updated": "2026-09-20" }
|
||||
{
|
||||
"file": "04-connect.md",
|
||||
"slug": "connect",
|
||||
"title": "新建连接",
|
||||
"updated": "2026-09-20"
|
||||
},
|
||||
{
|
||||
"file": "05-drivers.md",
|
||||
"slug": "drivers",
|
||||
"title": "驱动支持",
|
||||
"updated": "2026-09-20"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "SQL 工作台",
|
||||
"pages": [
|
||||
{ "file": "06-workbench.md", "slug": "workbench", "title": "SQL 工作台", "updated": "2026-09-20" },
|
||||
{ "file": "07-results.md", "slug": "results", "title": "结果与导出", "updated": "2026-09-20" },
|
||||
{ "file": "08-redis.md", "slug": "redis", "title": "Redis 工作台", "updated": "2026-09-20" }
|
||||
{
|
||||
"file": "06-workbench.md",
|
||||
"slug": "workbench",
|
||||
"title": "SQL 工作台",
|
||||
"updated": "2026-09-20"
|
||||
},
|
||||
{
|
||||
"file": "07-results.md",
|
||||
"slug": "results",
|
||||
"title": "结果与导出",
|
||||
"updated": "2026-09-20"
|
||||
},
|
||||
{
|
||||
"file": "08-redis.md",
|
||||
"slug": "redis",
|
||||
"title": "Redis 工作台",
|
||||
"updated": "2026-09-20"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "安全与 AI",
|
||||
"pages": [
|
||||
{ "file": "09-security.md", "slug": "security", "title": "安全设计", "updated": "2026-09-20" },
|
||||
{ "file": "10-ai.md", "slug": "ai", "title": "AI 助手", "updated": "2026-09-20" }
|
||||
{
|
||||
"file": "09-security.md",
|
||||
"slug": "security",
|
||||
"title": "安全设计",
|
||||
"updated": "2026-09-20"
|
||||
},
|
||||
{
|
||||
"file": "10-ai.md",
|
||||
"slug": "ai",
|
||||
"title": "AI 助手",
|
||||
"updated": "2026-10-15"
|
||||
},
|
||||
{
|
||||
"file": "12-mcp.md",
|
||||
"slug": "mcp",
|
||||
"title": "MCP 接入",
|
||||
"updated": "2026-10-15"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "参考",
|
||||
"pages": [
|
||||
{ "file": "11-faq.md", "slug": "faq", "title": "常见问题", "updated": "2026-09-20" }
|
||||
{
|
||||
"file": "11-faq.md",
|
||||
"slug": "faq",
|
||||
"title": "常见问题",
|
||||
"updated": "2026-09-20"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -584,6 +584,49 @@ func NewRouter(token string) *gin.Engine {
|
||||
c.JSON(http.StatusOK, aitools.GetMCPStatus())
|
||||
})
|
||||
|
||||
// MCP 策略读写(设置 → MCP):~/.opsxdb/mcp.json
|
||||
api.GET("/ai/mcp-policy", func(c *gin.Context) {
|
||||
p, err := aitools.LoadPolicy()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
path, _ := aitools.PolicyPath()
|
||||
c.JSON(http.StatusOK, gin.H{"policy": p, "path": path})
|
||||
})
|
||||
|
||||
api.PUT("/ai/mcp-policy", func(c *gin.Context) {
|
||||
var req struct {
|
||||
AllowedConnectionIDs []string `json:"allowed_connection_ids"`
|
||||
Mode string `json:"mode"`
|
||||
AllowDangerous bool `json:"allow_dangerous"`
|
||||
DefaultLimit int `json:"default_limit"`
|
||||
MaxLimit int `json:"max_limit"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
p := aitools.Policy{
|
||||
AllowedConnectionIDs: req.AllowedConnectionIDs,
|
||||
Mode: req.Mode,
|
||||
AllowDangerous: req.AllowDangerous,
|
||||
DefaultLimit: req.DefaultLimit,
|
||||
MaxLimit: req.MaxLimit,
|
||||
}
|
||||
if err := aitools.SavePolicy(p); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
saved, _ := aitools.LoadPolicy()
|
||||
path, _ := aitools.PolicyPath()
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "policy": saved, "path": path})
|
||||
})
|
||||
|
||||
api.GET("/ai/mcp-install", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, aitools.ProbeMCPInstall())
|
||||
})
|
||||
|
||||
api.POST("/ai/generate", func(c *gin.Context) {
|
||||
var req ai.ChatReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
||||
@@ -151,11 +151,35 @@
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-table"/></svg></div><div><h3>结果随手取</h3><p>结果集一键复制 / 导出 CSV / 生成 INSERT;双击单元格即复制,截断行数有提示。</p></div></div>
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-tree"/></svg></div><div><h3>对象树与表数据</h3><p>懒加载库 / 表 / 列;点表名直接看前 100 行,展开表即看列结构。</p></div></div>
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-database"/></svg></div><div><h3>Redis 工作台</h3><p>编辑器直接执行 <code>PING</code> / <code>GET</code> / <code>SET</code> / <code>KEYS</code>,键空间浏览、TTL 管理。</p></div></div>
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-sparkles"/></svg></div><div><h3>AI / MCP</h3><p>Ask 写 SQL;Agent 真查白名单库。可用 <code>npx @rlwj-lab/opsxdb-mcp</code> 接到 Cursor(默认只读)。</p></div></div>
|
||||
<div class="card row"><div class="card-icon"><svg class="i-lg"><use href="#i-sparkles"/></svg></div><div><h3>AI / MCP</h3><p>Ask 写 SQL;Agent 真查白名单库。设置 → MCP 一键复制 Cursor / Claude 配置;默认只读。</p></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- ===================== AI / MCP ===================== -->
|
||||
<section class="section" id="mcp">
|
||||
<div class="wrap">
|
||||
<h2 class="sec-title">AI / MCP:桌面连库,助手真查库</h2>
|
||||
<p class="sec-sub">MCP-first:Cursor、Claude Code 与侧栏 Agent 共用同一套只读工具与安全策略。</p>
|
||||
<div class="grid g3">
|
||||
<div class="card">
|
||||
<h3>本地优先</h3>
|
||||
<p>连接与策略在本机 <code>~/.opsxdb</code>;查询不经公网中转。默认 <strong>只读</strong>,写操作需显式开启并确认。</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>一键接入</h3>
|
||||
<p><code>npm i -g @rlwj-lab/opsxdb-mcp</code>,在设置 → MCP 复制客户端片段。详见 <a href="/docs/mcp" target="_blank" rel="noopener">MCP 接入文档</a>。</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Ask | Agent</h3>
|
||||
<p>Ask 只生成 SQL;Agent 多轮调工具查白名单库,步骤时间线可流式展示,结果可插入编辑器或结果区。</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="footnote" style="margin-top:1.25rem">安全边界:连接白名单、危险 SQL 拦截、审计日志。可写为可选能力,见文档说明。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== 驱动矩阵 ===================== -->
|
||||
<section class="section" id="drivers">
|
||||
<div class="wrap">
|
||||
@@ -250,6 +274,8 @@
|
||||
<details><summary>和 DBeaver / Navicat 有什么差别?</summary><p>OpsxDB 面向运维与开发的日常高频操作(连库、点表、写 SQL、取结果),界面密度和快捷键为「快」设计;本地优先、后端随桌面应用拉起、内置中文文档。重度管理功能(ER 图、数据比对等)在路线图中。</p></details>
|
||||
<details><summary>Oracle 支持好了吗?</summary><p>驱动代码已就绪(纯 Go 的 go-ora,无需 JVM),但本机 docker 验收在 Apple Silicon 上受镜像限制未通过,需 x86 环境或真库完成验收,详见文档《驱动支持》。</p></details>
|
||||
<details><summary>AI 功能需要联网吗?</summary><p>配置云端模型(如 DeepSeek)时,提问与结构上下文会发往该服务;可改用本地兼容端点。MCP / Agent 查库始终在本机执行,默认只读。</p></details>
|
||||
<details><summary>如何把 Cursor / Claude 接到 OpsxDB?</summary><p>安装 <code>@rlwj-lab/opsxdb-mcp</code> 并构建 Go 二进制,在桌面 <strong>设置 → MCP</strong> 勾选连接白名单、复制对应客户端配置。完整步骤见 <a href="/docs/mcp" target="_blank" rel="noopener">MCP 接入</a>。</p></details>
|
||||
|
||||
<details><summary>支持达梦 / MongoDB 吗?</summary><p>已列入路线:常用库 native 内置,长尾库(达梦、MongoDB、ZooKeeper、ES、InfluxDB)规划 agent 按需接入,驱动分发走独立仓库并校验 sha256。</p></details>
|
||||
</div>
|
||||
</div>
|
||||
@@ -283,6 +309,7 @@
|
||||
<div>
|
||||
<h4>资源</h4>
|
||||
<a href="/docs" target="_blank" rel="noopener">用户文档</a>
|
||||
<a href="/docs/mcp" target="_blank" rel="noopener">MCP 接入</a>
|
||||
<a href="/docs/drivers" target="_blank" rel="noopener">驱动验收状态</a>
|
||||
<a href="/docs/faq" target="_blank" rel="noopener">常见问题</a>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user