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:
logan committed 2026-10-15 14:13:20 +08:00
1 parent 5416e86379
commit 1513f4bc2d
20 files changed
+1549 -80

No files matched your search

+1 -1
View File
@@ -88,7 +88,7 @@
- 工具结果中的 SQL →「插入当前编辑器」 - 工具结果中的 SQL →「插入当前编辑器」
- `execute_query` 结果 → 可选「在结果网格打开」(限行) - `execute_query` 结果 → 可选「在结果网格打开」(限行)
- 设置页:MCP 开关说明、白名单、只读/可写、复制 Cursor `mcp.json` 片段 - ~~设置页:MCP 开关说明、白名单、只读/可写、复制 Cursor `mcp.json` 片段~~ ✅ 设置 → MCP
## 刻意首版不做 ## 刻意首版不做
+20
View File
@@ -253,3 +253,23 @@ confirm_bind = sha256(connection_id + "\n" + normalize(sql_or_command))
--- ---
*文档版本: v1.0 | 2026-10-15 CST | 先契约与小改,大 UI 后挂* *文档版本: v1.0 | 2026-10-15 CST | 先契约与小改,大 UI 后挂*
---
## 10. 仍未完清单(对照本契约 · 2026-10-15)
| 状态 | 项 |
|------|-----|
| ✅ | MCP 工具面、Ask\|Agent、SSE、P1 确认写、`run_id` / confirmBind、触顶 incomplete |
| ✅ | 设置 → **MCP** 子页(安装检测、只读/危险 SQL、白名单、多客户端片段) |
| ✅ | 官网 AI/MCP 块 + 文档 `/docs/mcp` |
| ⬜ | 会话落盘 + HTTP 续跑(`/api/ai/sessions`)+ UI「加预算继续」 |
| ⬜ | NeedConfirm 结束 run 为 `awaiting_confirm` outcome(语义对齐) |
| ⬜ | 同参循环拒 / 中段检查点接入主循环 |
| ⬜ | 忙时单槽排队 UI |
| ⬜ | Ask 真 token 流式(非假打字) |
| ⬜ | MCP「断开」结束外部 Cursor 会话(当前仅 UI 提示) |
| ⬜ | 设置页一键 `npm install -g`(现为复制命令;避免桌面代跑需授权) |
*本节随交付勾选;大 UI 续跑优先于 Evolver / 审批流。*
+5 -3
View File
@@ -1,9 +1,11 @@
{ {
"mcpServers": { "mcpServers": {
"opsxdb": { "opsxdb": {
"command": "/Users/logan/devops/opsxdb/bin/opsxdb-mcp", "command": "npx",
"args": [], "args": ["-y", "@rlwj-lab/opsxdb-mcp"],
"env": {} "env": {
"OPSXDB_MCP_BIN": "/absolute/path/to/opsxdb/bin/opsxdb-mcp"
}
} }
} }
} }
+1
View File
@@ -1,6 +1,7 @@
{ {
"allowed_connection_ids": ["YOUR_CONNECTION_ID"], "allowed_connection_ids": ["YOUR_CONNECTION_ID"],
"mode": "read_only", "mode": "read_only",
"allow_dangerous": false,
"default_limit": 100, "default_limit": 100,
"max_limit": 500 "max_limit": 500
} }
+271
View File
@@ -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]
}
+275
View File
@@ -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
}
+15
View File
@@ -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)
}
+62
View File
@@ -18,6 +18,9 @@ const (
type Policy struct { type Policy struct {
AllowedConnectionIDs []string `json:"allowed_connection_ids"` AllowedConnectionIDs []string `json:"allowed_connection_ids"`
Mode string `json:"mode"` // read_only | read_write Mode string `json:"mode"` // read_only | read_write
// 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"` DefaultLimit int `json:"default_limit,omitempty"`
MaxLimit int `json:"max_limit,omitempty"` MaxLimit int `json:"max_limit,omitempty"`
} }
@@ -114,3 +117,62 @@ func (p Policy) ClampLimit(n int) int {
} }
return n 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()
}
+68
View File
@@ -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) }
+4
View File
@@ -401,6 +401,10 @@ func (r *Runtime) executeQuery(ctx context.Context, args map[string]any) (any, e
if !r.Policy.WritesEnabled() { if !r.Policy.WritesEnabled() {
return fail(readReason) 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(永不静默写)。 // P1:可写已开启,但无确认令牌则返回 needConfirm(永不静默写)。
if !confirmed { if !confirmed {
r.audit(AuditEntry{Tool: ToolExecuteQuery, ConnectionID: id, OK: false, Error: "needConfirm", Summary: truncate(sqlText, 80)}) r.audit(AuditEntry{Tool: ToolExecuteQuery, ConnectionID: id, OK: false, Error: "needConfirm", Summary: truncate(sqlText, 80)})
+1 -1
View File
@@ -287,7 +287,7 @@ func TestExecuteQueryRejectConfirmWhenAgentFlag(t *testing.T) {
} }
rt.Audit = func(AuditEntry) {} rt.Audit = func(AuditEntry) {}
out, err := rt.Call(context.Background(), ToolExecuteQuery, map[string]any{ 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
+2
View File
@@ -15,6 +15,7 @@ type MCPStatus struct {
Mode string `json:"mode"` Mode string `json:"mode"`
WhitelistCount int `json:"whitelistCount"` WhitelistCount int `json:"whitelistCount"`
WritesEnabled bool `json:"writesEnabled"` WritesEnabled bool `json:"writesEnabled"`
AllowDangerous bool `json:"allowDangerous"`
Active bool `json:"active"` // 近 5 分钟有 source=mcp 审计 Active bool `json:"active"` // 近 5 分钟有 source=mcp 审计
Recent []AuditEntry `json:"recent,omitempty"` Recent []AuditEntry `json:"recent,omitempty"`
PolicyPath string `json:"policyPath,omitempty"` PolicyPath string `json:"policyPath,omitempty"`
@@ -37,6 +38,7 @@ func GetMCPStatus() MCPStatus {
} }
st.WhitelistCount = len(p.AllowedConnectionIDs) st.WhitelistCount = len(p.AllowedConnectionIDs)
st.WritesEnabled = p.WritesEnabled() st.WritesEnabled = p.WritesEnabled()
st.AllowDangerous = p.AllowDangerous
st.Configured = st.Configured || st.WhitelistCount > 0 st.Configured = st.Configured || st.WhitelistCount > 0
} }
if ap, err := AuditPath(); err == nil { if ap, err := AuditPath(); err == nil {
+6 -2
View File
@@ -30,10 +30,14 @@ Agent 支持 openai-chat(DeepSeek 等)、Anthropic messages、OpenAI respons
把同一套工具暴露给 Cursor / Claude / Codex: 把同一套工具暴露给 Cursor / Claude / Codex:
```bash ```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」点;悬停/点击可看最近摘要。 侧栏标题旁仅在外部 MCP **近期有调用**时显示绿色「MCP」点;悬停/点击可看最近摘要。
+109
View File
@@ -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 勾选连接白名单
+72 -11
View File
@@ -4,37 +4,98 @@
{ {
"name": "开始使用", "name": "开始使用",
"pages": [ "pages": [
{ "file": "01-intro.md", "slug": "intro", "title": "产品介绍", "updated": "2026-09-20" }, {
{ "file": "02-quickstart.md", "slug": "quickstart", "title": "快速开始", "updated": "2026-09-20" }, "file": "01-intro.md",
{ "file": "03-interface.md", "slug": "interface", "title": "界面导览", "updated": "2026-09-20" } "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": "连接与驱动", "name": "连接与驱动",
"pages": [ "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 工作台", "name": "SQL 工作台",
"pages": [ "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": "06-workbench.md",
{ "file": "08-redis.md", "slug": "redis", "title": "Redis 工作台", "updated": "2026-09-20" } "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", "name": "安全与 AI",
"pages": [ "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": "参考", "name": "参考",
"pages": [ "pages": [
{ "file": "11-faq.md", "slug": "faq", "title": "常见问题", "updated": "2026-09-20" } {
"file": "11-faq.md",
"slug": "faq",
"title": "常见问题",
"updated": "2026-09-20"
}
] ]
} }
] ]
+43
View File
@@ -584,6 +584,49 @@ func NewRouter(token string) *gin.Engine {
c.JSON(http.StatusOK, aitools.GetMCPStatus()) 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) { api.POST("/ai/generate", func(c *gin.Context) {
var req ai.ChatReq var req ai.ChatReq
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
+28 -1
View File
@@ -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-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-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-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>
</div> </div>
</section> </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"> <section class="section" id="drivers">
<div class="wrap"> <div class="wrap">
@@ -250,6 +274,8 @@
<details><summary>和 DBeaver / Navicat 有什么差别?</summary><p>OpsxDB 面向运维与开发的日常高频操作(连库、点表、写 SQL、取结果),界面密度和快捷键为「快」设计;本地优先、后端随桌面应用拉起、内置中文文档。重度管理功能(ER 图、数据比对等)在路线图中。</p></details> <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>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>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> <details><summary>支持达梦 / MongoDB 吗?</summary><p>已列入路线:常用库 native 内置,长尾库(达梦、MongoDB、ZooKeeper、ES、InfluxDB)规划 agent 按需接入,驱动分发走独立仓库并校验 sha256。</p></details>
</div> </div>
</div> </div>
@@ -283,6 +309,7 @@
<div> <div>
<h4>资源</h4> <h4>资源</h4>
<a href="/docs" target="_blank" rel="noopener">用户文档</a> <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/drivers" target="_blank" rel="noopener">驱动验收状态</a>
<a href="/docs/faq" target="_blank" rel="noopener">常见问题</a> <a href="/docs/faq" target="_blank" rel="noopener">常见问题</a>
</div> </div>
+397
View File
@@ -0,0 +1,397 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Check, Copy, ExternalLink, RefreshCw } from "lucide-react";
import {
getMcpInstall,
getMcpPolicy,
saveMcpPolicy,
type McpInstallInfo,
type McpPolicy,
} from "./api";
import { useWorkspaceStore } from "./store";
import { openExternal } from "./openExternal";
const MCP_DOCS_URL = "http://127.0.0.1:8788/docs/mcp";
const NPM_PKG = "@rlwj-lab/opsxdb-mcp";
type ClientTab = "cursor" | "claude-code" | "vscode" | "windsurf" | "codex" | "claude-desktop";
const CLIENT_TABS: Array<{ id: ClientTab; label: string }> = [
{ id: "cursor", label: "Cursor" },
{ id: "claude-code", label: "Claude Code" },
{ id: "vscode", label: "VS Code" },
{ id: "windsurf", label: "Windsurf" },
{ id: "codex", label: "Codex" },
{ id: "claude-desktop", label: "Claude Desktop" },
];
function buildClientSnippet(tab: ClientTab, goBin: string, readOnly: boolean): { path: string; json: string } {
const env: Record<string, string> = {};
if (goBin) env.OPSXDB_MCP_BIN = goBin;
// 权限以 ~/.opsxdb/mcp.json 为准;客户端 env 不放宽。
void readOnly;
const npxServer = {
command: "npx",
args: ["-y", NPM_PKG],
...(Object.keys(env).length ? { env } : {}),
};
const absServer = goBin
? { command: goBin, args: [] as string[] }
: npxServer;
switch (tab) {
case "claude-code":
return {
path: "项目根目录 .mcp.json",
json: JSON.stringify({ mcpServers: { opsxdb: npxServer } }, null, 2),
};
case "cursor":
return {
path: "~/.cursor/mcp.json 或项目 .cursor/mcp.json",
json: JSON.stringify({ mcpServers: { opsxdb: npxServer } }, null, 2),
};
case "vscode":
return {
path: ".vscode/mcp.json",
json: JSON.stringify(
{
servers: {
opsxdb: {
type: "stdio",
command: npxServer.command,
args: npxServer.args,
...(npxServer.env ? { env: npxServer.env } : {}),
},
},
},
null,
2,
),
};
case "windsurf":
return {
path: "~/.codeium/windsurf/mcp_config.json",
json: JSON.stringify({ mcpServers: { opsxdb: npxServer } }, null, 2),
};
case "codex":
return {
path: "~/.codex/config.toml(MCP 段)或对应 mcp.json",
json: JSON.stringify({ mcpServers: { opsxdb: absServer } }, null, 2),
};
case "claude-desktop":
return {
path: "Claude Desktop → Developer → Edit Config",
json: JSON.stringify({ mcpServers: { opsxdb: npxServer } }, null, 2),
};
}
}
function StatusBadge({ status }: { status: string }) {
const map: Record<string, { label: string; color: string; bg: string }> = {
ready: { label: "已就绪", color: "var(--ops-accent)", bg: "color-mix(in srgb, var(--ops-accent) 14%, transparent)" },
partial: { label: "部分就绪", color: "var(--ops-warning, #d97706)", bg: "color-mix(in srgb, #d97706 14%, transparent)" },
not_installed: { label: "未安装", color: "var(--ops-warning, #d97706)", bg: "color-mix(in srgb, #d97706 14%, transparent)" },
};
const m = map[status] ?? map.not_installed;
return (
<span
className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium"
style={{ color: m.color, background: m.bg }}
>
{m.label}
</span>
);
}
function Row({ label, children, hint }: { label: string; children: React.ReactNode; hint?: string }) {
return (
<div className="space-y-1">
<div className="flex items-center justify-between gap-3">
<span className="text-[12.5px]" style={{ color: "var(--ops-text)" }}>{label}</span>
{children}
</div>
{hint && <div className="text-[11px] leading-relaxed" style={{ color: "var(--ops-muted)" }}>{hint}</div>}
</div>
);
}
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
className="rounded-full border transition disabled:opacity-50"
style={{
height: 18,
width: 36,
borderColor: "var(--ops-border)",
background: checked ? "var(--ops-accent)" : "var(--ops-surface-2)",
}}
onClick={() => onChange(!checked)}
>
<span
className="block rounded-full bg-white transition-all"
style={{ width: 12, height: 12, marginLeft: checked ? 20 : 3, boxShadow: "0 1px 2px rgba(0,0,0,.3)" }}
/>
</button>
);
}
/** 设置 → MCP:安装检测、只读/危险 SQL、白名单、多客户端配置片段 */
export function McpSettings() {
const notify = useWorkspaceStore((s) => s.notify);
const connections = useWorkspaceStore((s) => s.connections);
const [install, setInstall] = useState<McpInstallInfo | null>(null);
const [policy, setPolicy] = useState<McpPolicy | null>(null);
const [policyPath, setPolicyPath] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [clientTab, setClientTab] = useState<ClientTab>("cursor");
const [copied, setCopied] = useState<"install" | "snippet" | "">("");
const [tick, setTick] = useState(0);
const reload = useCallback(() => setTick((n) => n + 1), []);
useEffect(() => {
let alive = true;
setLoading(true);
setError("");
Promise.all([getMcpInstall(), getMcpPolicy()])
.then(([inst, pol]) => {
if (!alive) return;
setInstall(inst);
setPolicy(pol.policy);
setPolicyPath(pol.path || "");
})
.catch((e) => {
if (alive) setError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [tick]);
const readOnly = !policy || policy.mode !== "read_write";
const allowDangerous = !!policy?.allow_dangerous;
const allowed = new Set(policy?.allowed_connection_ids ?? []);
const snippet = useMemo(
() => buildClientSnippet(clientTab, install?.goBinaryPath || "", readOnly),
[clientTab, install?.goBinaryPath, readOnly],
);
const persist = async (next: McpPolicy) => {
setSaving(true);
try {
const res = await saveMcpPolicy(next);
setPolicy(res.policy);
setPolicyPath(res.path || policyPath);
notify({ kind: "success", title: "MCP", message: "策略已保存", mode: "toast", durationMs: 1400 });
} catch (e) {
notify({ kind: "error", title: "MCP", message: e instanceof Error ? e.message : String(e), mode: "modal" });
} finally {
setSaving(false);
}
};
const copyText = async (text: string, which: "install" | "snippet") => {
try {
await navigator.clipboard.writeText(text);
setCopied(which);
setTimeout(() => setCopied(""), 1600);
notify({ kind: "success", title: "已复制", message: which === "install" ? "安装命令" : "配置片段", mode: "toast", durationMs: 1200 });
} catch {
notify({ kind: "error", title: "复制失败", message: "请手动选中复制", mode: "toast", durationMs: 2000 });
}
};
const toggleConn = (id: string) => {
if (!policy) return;
const set = new Set(policy.allowed_connection_ids ?? []);
if (set.has(id)) set.delete(id);
else set.add(id);
void persist({ ...policy, allowed_connection_ids: Array.from(set) });
};
if (loading) return <p role="status" className="text-xs" style={{ color: "var(--ops-muted)" }}>正在检测 MCP 环境…</p>;
if (error) {
return (
<div role="alert" className="space-y-2 text-xs">
<p>{error}</p>
<button type="button" className="ops-btn-secondary" onClick={reload}>重试</button>
</div>
);
}
const installCmd = install?.installCommand || `npm install -g ${NPM_PKG}@latest`;
return (
<div className="space-y-5 text-[12.5px]" style={{ color: "var(--ops-text)" }}>
{/* Header */}
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-[13px] font-semibold">MCP Server</h3>
<StatusBadge status={install?.status || "not_installed"} />
<button
type="button"
className="ops-btn-ghost ml-auto inline-flex h-6 items-center gap-1 px-2 text-[11px]"
onClick={() => void openExternal(MCP_DOCS_URL)}
title="打开 MCP 文档"
>
MCP 指南 <ExternalLink className="h-3 w-3" strokeWidth={1.5} />
</button>
</div>
{/* Version grid */}
<div className="grid grid-cols-2 gap-2">
{[
["当前版本", install?.currentVersion || (install?.goBinaryFound ? "native" : "未检测到全局安装")],
["最新版本", install?.latestVersion ? `v${install.latestVersion}` : "—"],
["Node.js", install?.nodeVersion || "未检测到"],
["npm", install?.npmAvailable ? (install.npmVersion || "可用") : "不可用"],
].map(([k, v]) => (
<div key={k} className="rounded-lg border px-2.5 py-2" style={{ borderColor: "var(--ops-border-soft)", background: "var(--ops-surface-2)" }}>
<div className="text-[10px] uppercase tracking-wide" style={{ color: "var(--ops-muted)" }}>{k}</div>
<div className="mt-0.5 truncate font-mono text-[11.5px]">{v}</div>
</div>
))}
</div>
{install?.goBinaryPath && (
<p className="text-[11px]" style={{ color: "var(--ops-muted)" }}>
Go 二进制:<span className="font-mono">{install.goBinaryPath}</span>
</p>
)}
{install?.hint && (
<p className="text-[11px] leading-relaxed" style={{ color: "var(--ops-muted)" }}>{install.hint}</p>
)}
{/* Install command */}
<div className="space-y-1.5">
<div className="text-[12px] font-medium">安装命令</div>
<div className="flex gap-1.5">
<code
className="min-w-0 flex-1 overflow-x-auto rounded border px-2 py-1.5 font-mono text-[11px]"
style={{ borderColor: "var(--ops-border)", background: "var(--ops-surface-2)" }}
>
{installCmd}
</code>
<button
type="button"
className="ops-btn-secondary inline-flex h-8 w-8 shrink-0 items-center justify-center"
aria-label="复制安装命令"
onClick={() => void copyText(installCmd, "install")}
>
{copied === "install" ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
<p className="text-[11px] leading-relaxed" style={{ color: "var(--ops-muted)" }}>
npm 包是 Node 启动器;还需本机 Go 二进制(<code className="font-mono">go build -o bin/opsxdb-mcp ./cmd/opsxdb-mcp</code>),
并在客户端配置里设置 <code className="font-mono">OPSXDB_MCP_BIN</code>。
</p>
</div>
{/* Toggles */}
<div className="space-y-3 border-t pt-3" style={{ borderColor: "var(--ops-border-soft)" }}>
<Row
label="只读模式"
hint="开启后 MCP/Agent 仅允许查询与元数据读取(mcp.json mode=read_only)。关闭后变为 read_write,写操作仍须确认。"
>
<Toggle
checked={readOnly}
disabled={saving || !policy}
onChange={(v) => {
if (!policy) return;
void persist({ ...policy, mode: v ? "read_only" : "read_write", allow_dangerous: v ? false : policy.allow_dangerous });
}}
/>
</Row>
<Row
label="允许危险 SQL"
hint="开启后,在非只读模式下经确认可执行 DROP / TRUNCATE / ALTER / 无 WHERE 的 UPDATE·DELETE 等。默认关闭。"
>
<Toggle
checked={allowDangerous}
disabled={saving || !policy || readOnly}
onChange={(v) => {
if (!policy) return;
void persist({ ...policy, allow_dangerous: v });
}}
/>
</Row>
</div>
{/* Whitelist */}
<div className="space-y-2 border-t pt-3" style={{ borderColor: "var(--ops-border-soft)" }}>
<div className="text-[12px] font-medium">连接白名单</div>
<p className="text-[11px] leading-relaxed" style={{ color: "var(--ops-muted)" }}>
空白名单时 MCP/Agent <strong>拒绝全部连接</strong>。勾选后写入 <code className="font-mono">~/.opsxdb/mcp.json</code>
{policyPath ? `(${policyPath})` : ""}。
</p>
{connections.length === 0 ? (
<p className="text-[11px]" style={{ color: "var(--ops-muted)" }}>暂无已保存连接。请先在侧栏新建连接。</p>
) : (
<div className="max-h-36 space-y-1 overflow-y-auto rounded border p-2" style={{ borderColor: "var(--ops-border-soft)" }}>
{connections.map((c) => (
<label key={c.id} className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 hover:bg-[color-mix(in_srgb,var(--ops-accent)_8%,transparent)]">
<input
type="checkbox"
checked={allowed.has(c.id)}
disabled={saving}
onChange={() => toggleConn(c.id)}
/>
<span className="min-w-0 flex-1 truncate">{c.name || c.id}</span>
<span className="shrink-0 font-mono text-[10px]" style={{ color: "var(--ops-muted)" }}>{c.driver}</span>
</label>
))}
</div>
)}
</div>
{/* Client configs */}
<div className="space-y-2 border-t pt-3" style={{ borderColor: "var(--ops-border-soft)" }}>
<div className="text-[12px] font-medium">MCP 配置</div>
<div className="flex flex-wrap gap-1">
{CLIENT_TABS.map((t) => (
<button
key={t.id}
type="button"
className={`rounded px-2 py-1 text-[11px] ${clientTab === t.id ? "ops-tree-item-active" : "ops-btn-ghost"}`}
onClick={() => setClientTab(t.id)}
>
{t.label}
</button>
))}
</div>
<p className="text-[11px]" style={{ color: "var(--ops-muted)" }}>写入位置:{snippet.path}</p>
<div className="relative">
<pre
className="max-h-48 overflow-auto rounded border p-2 font-mono text-[10.5px] leading-relaxed"
style={{ borderColor: "var(--ops-border)", background: "var(--ops-surface-2)" }}
>
{snippet.json}
</pre>
<button
type="button"
className="ops-btn-secondary absolute right-2 top-2 inline-flex h-7 items-center gap-1 px-2 text-[11px]"
onClick={() => void copyText(snippet.json, "snippet")}
>
{copied === "snippet" ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
复制
</button>
</div>
</div>
<div className="flex flex-wrap gap-2 border-t pt-3" style={{ borderColor: "var(--ops-border-soft)" }}>
<button type="button" className="ops-btn-secondary inline-flex h-7 items-center gap-1 px-2.5 text-[11px]" onClick={reload} disabled={loading}>
<RefreshCw className="h-3 w-3" /> 重新检查
</button>
</div>
</div>
);
}
+119 -61
View File
@@ -1,20 +1,44 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import {
ExternalLink,
Info,
Keyboard,
Monitor,
Puzzle,
Server,
Shield,
Sparkles,
Table,
Zap,
type LucideIcon,
} from "lucide-react";
import { driverStatus, health, type DriverStatusItem } from "./api"; import { driverStatus, health, type DriverStatusItem } from "./api";
import { OpsModal } from "./OpsModal"; import { OpsModal } from "./OpsModal";
import { AiSettings } from "./AiPanel";
import { McpSettings } from "./McpSettings";
import { DriverManager } from "./DriverManager";
import appPackage from "../package.json";
import { useWorkspaceStore } from "./store"; import { useWorkspaceStore } from "./store";
import { openExternal } from "./openExternal";
const IS_MAC = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform); const IS_MAC = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
const MOD = IS_MAC ? "⌘" : "Ctrl"; const MOD = IS_MAC ? "⌘" : "Ctrl";
type TabId = "general" | "execution" | "results" | "drivers" | "shortcuts" | "about"; const WECHAT_GROUP_URL = "https://docs.qq.com/doc/DTFpxcndud0xPd1hX";
const DRIVER_STORE_URL = "https://gitee.com/attacker/opsxdb-driver";
const TABS: Array<{ id: TabId; icon: string; label: string; desc: string }> = [ type TabId = "general" | "execution" | "results" | "ai" | "mcp" | "security" | "drivers" | "shortcuts" | "about";
{ id: "general", icon: "🎨", label: "常规", desc: "主题与界面" },
{ id: "execution", icon: "⚡", label: "执行", desc: "超时与多语句" }, const TABS: Array<{ id: TabId; icon: LucideIcon; label: string; desc: string }> = [
{ id: "results", icon: "📊", label: "结果", desc: "面板与分页" }, { id: "general", icon: Monitor, label: "常规", desc: "主题与界面" },
{ id: "drivers", icon: "🧩", label: "驱动", desc: "agent 驱动状态" }, { id: "execution", icon: Zap, label: "执行", desc: "超时与多语句" },
{ id: "shortcuts", icon: "⌨️", label: "快捷键", desc: "键盘操作一览" }, { id: "results", icon: Table, label: "结果", desc: "面板与分页" },
{ id: "about", icon: "ℹ️", label: "关于", desc: "版本与数据目录" }, { id: "ai", icon: Sparkles, label: "AI 助手", desc: "模型、API Key 与连接测试" },
{ id: "mcp", icon: Server, label: "MCP", desc: "安装检测、只读与客户端配置" },
{ id: "security", icon: Shield, label: "安全与数据", desc: "本机存储与执行保护" },
{ id: "drivers", icon: Puzzle, label: "驱动", desc: "agent 驱动状态" },
{ id: "shortcuts", icon: Keyboard, label: "快捷键", desc: "键盘操作一览" },
{ id: "about", icon: Info, label: "关于", desc: "版本与资源" },
]; ];
function Row({ label, children, hint }: { label: string; children: React.ReactNode; hint?: string }) { function Row({ label, children, hint }: { label: string; children: React.ReactNode; hint?: string }) {
@@ -69,6 +93,22 @@ function Seg<T extends string | number>({ value, options, onChange }: { value: T
); );
} }
function AboutLink({ label, description, onClick }: { label: string; description: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors hover:bg-[color-mix(in_srgb,var(--ops-accent)_8%,transparent)]"
>
<div className="min-w-0 flex-1">
<div className="text-[12.5px] font-medium" style={{ color: "var(--ops-text)" }}>{label}</div>
<div className="mt-0.5 text-[11px] leading-relaxed" style={{ color: "var(--ops-muted)" }}>{description}</div>
</div>
<ExternalLink className="h-3.5 w-3.5 shrink-0" style={{ color: "var(--ops-muted)" }} aria-hidden strokeWidth={1.5} />
</button>
);
}
/** 设置弹框:左分类导航 + 右面板(对标 opsxshell / DBeaver Preferences) */ /** 设置弹框:左分类导航 + 右面板(对标 opsxshell / DBeaver Preferences) */
export function SettingsModal({ onClose }: { onClose: () => void }) { export function SettingsModal({ onClose }: { onClose: () => void }) {
const [tab, setTab] = useState<TabId>("general"); const [tab, setTab] = useState<TabId>("general");
@@ -79,14 +119,20 @@ export function SettingsModal({ onClose }: { onClose: () => void }) {
const resultRatio = useWorkspaceStore((s) => s.resultRatio); const resultRatio = useWorkspaceStore((s) => s.resultRatio);
const set = useWorkspaceStore((s) => s.set); const set = useWorkspaceStore((s) => s.set);
const notify = useWorkspaceStore((s) => s.notify); const notify = useWorkspaceStore((s) => s.notify);
const openConnectionList = useWorkspaceStore((s) => s.openConnectionList); const [showDriverManager, setShowDriverManager] = useState(false);
const [driverError, setDriverError] = useState("");
const [drivers, setDrivers] = useState<DriverStatusItem[] | null>(null); const [drivers, setDrivers] = useState<DriverStatusItem[] | null>(null);
const [navSearch, setNavSearch] = useState(""); const [navSearch, setNavSearch] = useState("");
const [version, setVersion] = useState<string>(""); const [version, setVersion] = useState<string>("");
const reloadDrivers = () => {
setDriverError("");
setDrivers(null);
void driverStatus().then(setDrivers).catch(() => setDriverError("驱动状态加载失败,请确认本机服务已启动。"));
};
useEffect(() => { useEffect(() => {
void driverStatus().then(setDrivers).catch(() => setDrivers([])); reloadDrivers();
void health().then((h) => setVersion(String((h as { version?: string }).version ?? ""))).catch(() => {}); void health().then((h) => setVersion(String((h as { version?: string }).version ?? ""))).catch(() => {});
}, []); }, []);
@@ -97,59 +143,74 @@ export function SettingsModal({ onClose }: { onClose: () => void }) {
notify({ kind: "success", title: "已重置", message: "面板布局恢复默认", mode: "toast", durationMs: 1400 }); notify({ kind: "success", title: "已重置", message: "面板布局恢复默认", mode: "toast", durationMs: 1400 });
}; };
const visibleTabs = TABS.filter((t) => (t.label + t.desc + (t.id === "general" ? "主题 深色 浅色 布局" : "") + (t.id === "mcp" ? "mcp cursor claude npm 只读 白名单" : "") + (t.id === "ai" ? "deepseek 模型 agent" : "")).toLowerCase().includes(navSearch.trim().toLowerCase()));
const activeTab = visibleTabs.find((t) => t.id === tab) ?? visibleTabs[0];
const active = activeTab?.id;
return ( return (
<OpsModal variant="info" title="设置" onOk={onClose}> <>
<div className="flex" style={{ minWidth: 560, minHeight: 340 }}> <OpsModal wide variant="info" title="设置" okLabel="完成" onOk={onClose}>
{/* 左导航:搜索 + 图标主标题 + 副文案(对齐 opsxshell 质感) */} <div className="flex" style={{ height: "min(520px, 65vh)", minHeight: 260 }}>
<nav className="w-52 shrink-0 border-r pr-1" style={{ borderColor: "var(--ops-border-soft)" }}> {/* 左导航:搜索 + 线框图标 + 主标题/副文案(对齐 opsxshell) */}
<nav aria-label="设置分类" className="w-44 shrink-0 overflow-y-auto border-r pr-3" style={{ borderColor: "var(--ops-border-soft)" }}>
<input <input
className="ops-input mb-2 h-7 w-full text-[12px]" className="ops-input mb-2 h-7 w-full text-[12px]"
placeholder="搜索设置…" placeholder="搜索设置…"
aria-label="搜索设置"
value={navSearch} value={navSearch}
onChange={(e) => setNavSearch(e.target.value)} onChange={(e) => setNavSearch(e.target.value)}
/> />
{TABS.filter((t) => { {visibleTabs.map((t) => {
const q = navSearch.trim().toLowerCase(); const Icon = t.icon;
if (!q) return true; const selected = active === t.id;
return (t.label + t.desc).toLowerCase().includes(q); return (
}).map((t) => (
<button <button
key={t.id} key={t.id}
type="button" type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-2 text-left transition ${ className={`flex w-full items-center gap-2 rounded px-2 py-2 text-left transition ${
tab === t.id selected
? "bg-[color-mix(in_srgb,var(--ops-accent)_12%,transparent)]" ? "bg-[color-mix(in_srgb,var(--ops-accent)_12%,transparent)]"
: "opacity-75 hover:opacity-100" : "opacity-75 hover:opacity-100"
}`} }`}
style={tab === t.id ? { boxShadow: "inset 2px 0 0 var(--ops-accent)" } : undefined} style={selected ? { boxShadow: "inset 2px 0 0 var(--ops-accent)" } : undefined}
aria-current={selected ? "page" : undefined}
onClick={() => setTab(t.id)} onClick={() => setTab(t.id)}
> >
<span className="w-4 text-center text-[13px]" style={tab === t.id ? { color: "var(--ops-accent)" } : { color: "var(--ops-muted)" }}> <span className="grid h-4 w-4 shrink-0 place-items-center" style={{ color: selected ? "var(--ops-accent)" : "var(--ops-muted)" }}>
{t.icon} <Icon className="h-4 w-4" strokeWidth={1.5} aria-hidden />
</span> </span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block text-[12px] font-medium">{t.label}</span> <span className="block text-[12px] font-medium">{t.label}</span>
<span className="block text-[10px]" style={{ color: "var(--ops-muted)" }}>{t.desc}</span> <span className="block text-[10px]" style={{ color: "var(--ops-muted)" }}>{t.desc}</span>
</span> </span>
</button> </button>
))} );
})}
</nav> </nav>
{/* 右面板 */} {/* 右面板 */}
<div className="min-w-0 flex-1 space-y-4 pl-4"> <div className="min-w-0 flex-1 space-y-4 overflow-y-auto pl-4 pr-1">
{tab === "general" && ( {activeTab ? <div className="border-b pb-3" style={{ borderColor: "var(--ops-border-soft)" }}><h2 className="text-base font-semibold">{activeTab.label}</h2></div> : <div className="py-12 text-center"><p>没有匹配的设置</p><button className="ops-btn-secondary mt-3" onClick={() => setNavSearch("")}>清空搜索</button></div>}
{active === "ai" && <AiSettings onClose={() => setTab("general")} />}
{active === "mcp" && <McpSettings />}
{active === "security" && <div className="space-y-4 text-xs leading-relaxed"><Row label="危险语句确认" hint="DROP、TRUNCATE 和无 WHERE 删除等操作,执行前需确认。"><span>始终开启</span></Row><Row label="连接与查询" hint="连接信息、查询标签与界面偏好保存在本机。连接密码目前仅作混淆保存,并非系统钥匙串加密。"><span>本机存储</span></Row><Row label="AI 数据" hint="使用 AI 时,输入的问题与选取的结构上下文会发送至所配置的 AI 服务。API Key 保存后不回显明文。"><span>按需发送</span></Row></div>}
{active === "general" && (
<> <>
<Row label="主题"> <Row label="主题">
<Seg <Seg
value={theme} value={theme}
options={[{ v: "dark" as const, label: "深色" }, { v: "light" as const, label: "浅色" }]} options={[
{ v: "dark" as const, label: "深色" },
{ v: "light" as const, label: "浅色" },
{ v: "system" as const, label: "随系统" },
]}
onChange={(v) => set("theme", v)} onChange={(v) => set("theme", v)}
/> />
</Row> </Row>
<Row label="显示 AI 面板" hint="工作台右侧的 AI 助手面板"> <Row label="显示 AI 面板">
<Toggle checked={showAi} onChange={(v) => set("showAi", v)} /> <Toggle checked={showAi} onChange={(v) => set("showAi", v)} />
</Row> </Row>
<Row label="面板布局" hint="侧栏宽度、AI 面板宽度、结果面板占比"> <Row label="面板布局">
<button type="button" className="ops-btn-secondary h-6 px-2 text-[11px]" onClick={resetLayout}> <button type="button" className="ops-btn-secondary h-6 px-2 text-[11px]" onClick={resetLayout}>
恢复默认 恢复默认
</button> </button>
@@ -157,7 +218,7 @@ export function SettingsModal({ onClose }: { onClose: () => void }) {
</> </>
)} )}
{tab === "execution" && ( {active === "execution" && (
<> <>
<Row label="默认执行超时" hint="单语句执行上限,超时由服务端强制中断"> <Row label="默认执行超时" hint="单语句执行上限,超时由服务端强制中断">
<Seg <Seg
@@ -172,7 +233,7 @@ export function SettingsModal({ onClose }: { onClose: () => void }) {
onChange={(v) => set("execTimeoutMs", v)} onChange={(v) => set("execTimeoutMs", v)}
/> />
</Row> </Row>
<Row label="多语句遇错即停" hint="一次执行多条时,某条失败即停止后续"> <Row label="多语句遇错即停">
<Toggle checked={stopOnError} onChange={(v) => set("stopOnError", v)} /> <Toggle checked={stopOnError} onChange={(v) => set("stopOnError", v)} />
</Row> </Row>
<Row label="危险语句确认" hint="DROP/TRUNCATE/无 WHERE 删除等由服务端权威拦截,执行前需二次确认(不可关闭)"> <Row label="危险语句确认" hint="DROP/TRUNCATE/无 WHERE 删除等由服务端权威拦截,执行前需二次确认(不可关闭)">
@@ -181,9 +242,9 @@ export function SettingsModal({ onClose }: { onClose: () => void }) {
</> </>
)} )}
{tab === "results" && ( {active === "results" && (
<> <>
<Row label={`结果面板占比 ${Math.round(resultRatio * 100)}%`} hint="编辑器与结果面板的高度分配"> <Row label={`结果面板占比 ${Math.round(resultRatio * 100)}%`}>
<input <input
type="range" type="range"
min={25} min={25}
@@ -199,10 +260,12 @@ export function SettingsModal({ onClose }: { onClose: () => void }) {
</> </>
)} )}
{tab === "drivers" && ( {active === "drivers" && (
<> <>
<div className="space-y-1.5"> <div className="space-y-1.5">
{drivers === null && <div className="text-[11px]" style={{ color: "var(--ops-muted)" }}>加载中…</div>} {driverError && <div role="alert" className="text-xs">{driverError}<button className="ops-btn-secondary ml-2" onClick={reloadDrivers}>重试</button></div>}
{drivers?.length === 0 && <p className="text-xs">暂无需要管理的驱动。</p>}
{!driverError && drivers === null && <div className="text-[11px]" style={{ color: "var(--ops-muted)" }}>加载中…</div>}
{drivers?.map((d) => ( {drivers?.map((d) => (
<div key={d.id} className="flex items-center justify-between rounded border px-2 py-1.5 text-[11.5px]" style={{ borderColor: "var(--ops-border-soft)" }}> <div key={d.id} className="flex items-center justify-between rounded border px-2 py-1.5 text-[11.5px]" style={{ borderColor: "var(--ops-border-soft)" }}>
<span>{d.id}</span> <span>{d.id}</span>
@@ -212,15 +275,11 @@ export function SettingsModal({ onClose }: { onClose: () => void }) {
</div> </div>
))} ))}
</div> </div>
<Row label="驱动管理" hint="预装下载、离线包导入、JDK 检测"> <Row label="驱动管理">
<button <button
type="button" type="button"
className="ops-btn-secondary h-6 px-2 text-[11px]" className="ops-btn-secondary h-6 px-2 text-[11px]"
onClick={() => { onClick={() => setShowDriverManager(true)}
onClose();
openConnectionList();
notify({ kind: "info", title: "驱动管理", message: "连接列表页右上角 ⚙ 打开驱动管理", mode: "toast", durationMs: 2600 });
}}
> >
去管理 去管理
</button> </button>
@@ -228,7 +287,7 @@ export function SettingsModal({ onClose }: { onClose: () => void }) {
</> </>
)} )}
{tab === "shortcuts" && ( {active === "shortcuts" && (
<div className="space-y-1.5"> <div className="space-y-1.5">
{[ {[
[`${MOD} + Enter`, "执行当前语句或选区"], [`${MOD} + Enter`, "执行当前语句或选区"],
@@ -238,7 +297,8 @@ export function SettingsModal({ onClose }: { onClose: () => void }) {
["Ctrl + Tab", "循环切换查询"], ["Ctrl + Tab", "循环切换查询"],
[`${MOD} + ⇧ + F`, "聚焦对象树过滤框"], [`${MOD} + ⇧ + F`, "聚焦对象树过滤框"],
[`${MOD} + B`, "显示/隐藏 AI"], [`${MOD} + B`, "显示/隐藏 AI"],
[`${MOD} + /`, "快捷键帮助"], [`${MOD} + /`, "注释 / 取消注释"],
[`${MOD} + ⇧ + /`, "快捷键帮助"],
["双击表名", "打开前 100 行"], ["双击表名", "打开前 100 行"],
["双击单元格", "进入行编辑"], ["双击单元格", "进入行编辑"],
].map(([k, v]) => ( ].map(([k, v]) => (
@@ -250,30 +310,28 @@ export function SettingsModal({ onClose }: { onClose: () => void }) {
</div> </div>
)} )}
{tab === "about" && ( {active === "about" && (
<div className="space-y-2 text-[12px]" style={{ color: "var(--ops-text)" }}> <div className="max-w-xl space-y-5 text-[12px]" style={{ color: "var(--ops-text)" }}>
<div className="flex justify-between"><span style={{ color: "var(--ops-muted)" }}>版本</span><span className="font-mono">{version || "—"}</span></div> <p className="px-1 text-[11px]" style={{ color: "var(--ops-muted)" }}>
<div className="flex justify-between"><span style={{ color: "var(--ops-muted)" }}>形态</span><span>桌面客户端(Tauri 2)</span></div> <span className="font-mono">OpsxDB {appPackage.version}</span>
<div className="flex justify-between gap-4"> <span aria-hidden="true"> · </span>
<span style={{ color: "var(--ops-muted)" }}>驱动目录</span> 桌面客户端
<span className="truncate font-mono" title="~/.opsxdb/drivers">~/.opsxdb/drivers</span> <span aria-hidden="true"> · </span>
本机服务 {version || "未连接"}
</p>
<div>
<div className="mb-2 px-1 text-[11px] font-medium" style={{ color: "var(--ops-muted)" }}>资源</div>
<div className="divide-y divide-[color:var(--ops-border-soft)] overflow-hidden rounded-lg border" style={{ borderColor: "var(--ops-border-soft)" }}>
<AboutLink label="交流群" description="问题反馈与版本动态" onClick={() => void openExternal(WECHAT_GROUP_URL)} />
<AboutLink label="驱动商店" description="查看可用驱动与安装说明" onClick={() => void openExternal(DRIVER_STORE_URL)} />
</div> </div>
<div className="flex justify-between gap-4">
<span style={{ color: "var(--ops-muted)" }}>驱动商店</span>
<a
className="hover:underline"
style={{ color: "var(--ops-accent)" }}
href="https://gitee.com/attacker/opsxdb-driver"
target="_blank"
rel="noreferrer"
>
gitee.com/attacker/opsxdb-driver
</a>
</div> </div>
</div> </div>
)} )}
</div> </div>
</div> </div>
</OpsModal> </OpsModal>
{showDriverManager && <DriverManager onClose={() => { setShowDriverManager(false); reloadDrivers(); }} />}
</>
); );
} }
+50
View File
@@ -684,14 +684,64 @@ export type McpStatus = {
mode: string; mode: string;
whitelistCount: number; whitelistCount: number;
writesEnabled: boolean; writesEnabled: boolean;
allowDangerous?: boolean;
active: boolean; active: boolean;
recent?: { time: string; tool: string; ok: boolean; summary?: string; error?: string; source?: string }[]; recent?: { time: string; tool: string; ok: boolean; summary?: string; error?: string; source?: string }[];
policyPath?: string;
};
export type McpPolicy = {
allowed_connection_ids: string[];
mode: "read_only" | "read_write" | string;
allow_dangerous?: boolean;
default_limit?: number;
max_limit?: number;
};
export type McpInstallInfo = {
status: "ready" | "partial" | "not_installed" | string;
installed: boolean;
currentVersion?: string;
latestVersion?: string;
nodeVersion?: string;
npmAvailable: boolean;
npmVersion?: string;
npmPackageInstalled: boolean;
npmPackageVersion?: string;
goBinaryFound: boolean;
goBinaryPath?: string;
installCommand: string;
npxCommand?: string;
policyPath?: string;
hint?: string;
}; };
export async function getMcpStatus(signal?: AbortSignal) { export async function getMcpStatus(signal?: AbortSignal) {
return get<McpStatus>("/api/ai/mcp-status", signal); return get<McpStatus>("/api/ai/mcp-status", signal);
} }
export async function getMcpPolicy(signal?: AbortSignal) {
return get<{ policy: McpPolicy; path?: string }>("/api/ai/mcp-policy", signal);
}
export async function saveMcpPolicy(policy: McpPolicy, signal?: AbortSignal) {
const res = await fetch(`${API}/api/ai/mcp-policy`, {
method: "PUT",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify(policy),
signal,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error((data as { error?: string }).error || res.statusText || "request failed");
}
return data as { ok: boolean; policy: McpPolicy; path?: string };
}
export async function getMcpInstall(signal?: AbortSignal) {
return get<McpInstallInfo>("/api/ai/mcp-install", signal);
}
export async function confirmAiAgentWrite(payload: { export async function confirmAiAgentWrite(payload: {
connectionId: string; connectionId: string;
sql?: string; sql?: string;