diff --git a/docs/15-AI-MCP-Agent.md b/docs/15-AI-MCP-Agent.md index 5873550..9721302 100644 --- a/docs/15-AI-MCP-Agent.md +++ b/docs/15-AI-MCP-Agent.md @@ -88,7 +88,7 @@ - 工具结果中的 SQL →「插入当前编辑器」 - `execute_query` 结果 → 可选「在结果网格打开」(限行) -- 设置页:MCP 开关说明、白名单、只读/可写、复制 Cursor `mcp.json` 片段 +- ~~设置页:MCP 开关说明、白名单、只读/可写、复制 Cursor `mcp.json` 片段~~ ✅ 设置 → MCP ## 刻意首版不做 diff --git a/docs/16-AI-Session-Harness.md b/docs/16-AI-Session-Harness.md index a83550e..e4a6789 100644 --- a/docs/16-AI-Session-Harness.md +++ b/docs/16-AI-Session-Harness.md @@ -253,3 +253,23 @@ confirm_bind = sha256(connection_id + "\n" + normalize(sql_or_command)) --- *文档版本: 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 / 审批流。* diff --git a/docs/mcp-cursor.example.json b/docs/mcp-cursor.example.json index 66c4a67..94ddf03 100644 --- a/docs/mcp-cursor.example.json +++ b/docs/mcp-cursor.example.json @@ -1,9 +1,11 @@ { "mcpServers": { "opsxdb": { - "command": "/Users/logan/devops/opsxdb/bin/opsxdb-mcp", - "args": [], - "env": {} + "command": "npx", + "args": ["-y", "@rlwj-lab/opsxdb-mcp"], + "env": { + "OPSXDB_MCP_BIN": "/absolute/path/to/opsxdb/bin/opsxdb-mcp" + } } } } diff --git a/docs/mcp.json.example b/docs/mcp.json.example index 00e1491..a290b4a 100644 --- a/docs/mcp.json.example +++ b/docs/mcp.json.example @@ -1,6 +1,7 @@ { "allowed_connection_ids": ["YOUR_CONNECTION_ID"], "mode": "read_only", + "allow_dangerous": false, "default_limit": 100, "max_limit": 500 } diff --git a/server/cmd/opsxdb-crud-accept/main.go b/server/cmd/opsxdb-crud-accept/main.go new file mode 100644 index 0000000..88c7ce9 --- /dev/null +++ b/server/cmd/opsxdb-crud-accept/main.go @@ -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] +} diff --git a/server/internal/aitools/install.go b/server/internal/aitools/install.go new file mode 100644 index 0000000..8d2e683 --- /dev/null +++ b/server/internal/aitools/install.go @@ -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 +} diff --git a/server/internal/aitools/install_test.go b/server/internal/aitools/install_test.go new file mode 100644 index 0000000..60e67e5 --- /dev/null +++ b/server/internal/aitools/install_test.go @@ -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) +} diff --git a/server/internal/aitools/policy.go b/server/internal/aitools/policy.go index 37095ef..bef50d9 100644 --- a/server/internal/aitools/policy.go +++ b/server/internal/aitools/policy.go @@ -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() +} diff --git a/server/internal/aitools/policy_test.go b/server/internal/aitools/policy_test.go new file mode 100644 index 0000000..3c1176a --- /dev/null +++ b/server/internal/aitools/policy_test.go @@ -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) } diff --git a/server/internal/aitools/runtime.go b/server/internal/aitools/runtime.go index a19c6b7..b98cc28 100644 --- a/server/internal/aitools/runtime.go +++ b/server/internal/aitools/runtime.go @@ -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)}) diff --git a/server/internal/aitools/runtime_test.go b/server/internal/aitools/runtime_test.go index 8b913c5..3c19a8b 100644 --- a/server/internal/aitools/runtime_test.go +++ b/server/internal/aitools/runtime_test.go @@ -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) diff --git a/server/internal/aitools/status.go b/server/internal/aitools/status.go index d63f23a..1567328 100644 --- a/server/internal/aitools/status.go +++ b/server/internal/aitools/status.go @@ -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 { diff --git a/server/internal/docs/content/10-ai.md b/server/internal/docs/content/10-ai.md index 9a2bbaa..ea5921d 100644 --- a/server/internal/docs/content/10-ai.md +++ b/server/internal/docs/content/10-ai.md @@ -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」点;悬停/点击可看最近摘要。 diff --git a/server/internal/docs/content/12-mcp.md b/server/internal/docs/content/12-mcp.md new file mode 100644 index 0000000..2a49755 --- /dev/null +++ b/server/internal/docs/content/12-mcp.md @@ -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 勾选连接白名单 diff --git a/server/internal/docs/content/manifest.json b/server/internal/docs/content/manifest.json index 2f6834a..131f7d4 100644 --- a/server/internal/docs/content/manifest.json +++ b/server/internal/docs/content/manifest.json @@ -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" + } ] } ] diff --git a/server/internal/server/router.go b/server/internal/server/router.go index fb66c70..cd65046 100644 --- a/server/internal/server/router.go +++ b/server/internal/server/router.go @@ -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 { diff --git a/server/internal/site/template.html b/server/internal/site/template.html index 9a0c8a7..9cf85b9 100644 --- a/server/internal/site/template.html +++ b/server/internal/site/template.html @@ -151,11 +151,35 @@
结果集一键复制 / 导出 CSV / 生成 INSERT;双击单元格即复制,截断行数有提示。
懒加载库 / 表 / 列;点表名直接看前 100 行,展开表即看列结构。
编辑器直接执行 PING / GET / SET / KEYS,键空间浏览、TTL 管理。
Ask 写 SQL;Agent 真查白名单库。可用 npx @rlwj-lab/opsxdb-mcp 接到 Cursor(默认只读)。
Ask 写 SQL;Agent 真查白名单库。设置 → MCP 一键复制 Cursor / Claude 配置;默认只读。
MCP-first:Cursor、Claude Code 与侧栏 Agent 共用同一套只读工具与安全策略。
+连接与策略在本机 ~/.opsxdb;查询不经公网中转。默认 只读,写操作需显式开启并确认。
npm i -g @rlwj-lab/opsxdb-mcp,在设置 → MCP 复制客户端片段。详见 MCP 接入文档。
Ask 只生成 SQL;Agent 多轮调工具查白名单库,步骤时间线可流式展示,结果可插入编辑器或结果区。
+安全边界:连接白名单、危险 SQL 拦截、审计日志。可写为可选能力,见文档说明。
+OpsxDB 面向运维与开发的日常高频操作(连库、点表、写 SQL、取结果),界面密度和快捷键为「快」设计;本地优先、后端随桌面应用拉起、内置中文文档。重度管理功能(ER 图、数据比对等)在路线图中。
驱动代码已就绪(纯 Go 的 go-ora,无需 JVM),但本机 docker 验收在 Apple Silicon 上受镜像限制未通过,需 x86 环境或真库完成验收,详见文档《驱动支持》。
配置云端模型(如 DeepSeek)时,提问与结构上下文会发往该服务;可改用本地兼容端点。MCP / Agent 查库始终在本机执行,默认只读。
安装 @rlwj-lab/opsxdb-mcp 并构建 Go 二进制,在桌面 设置 → MCP 勾选连接白名单、复制对应客户端配置。完整步骤见 MCP 接入。
已列入路线:常用库 native 内置,长尾库(达梦、MongoDB、ZooKeeper、ES、InfluxDB)规划 agent 按需接入,驱动分发走独立仓库并校验 sha256。
正在检测 MCP 环境…
; + if (error) { + return ( +{error}
+ ++ Go 二进制:{install.goBinaryPath} +
+ )} + {install?.hint && ( +{install.hint}
+ )} + + {/* Install command */} +
+ {installCmd}
+
+
+
+ npm 包是 Node 启动器;还需本机 Go 二进制(go build -o bin/opsxdb-mcp ./cmd/opsxdb-mcp),
+ 并在客户端配置里设置 OPSXDB_MCP_BIN。
+
+ 空白名单时 MCP/Agent 拒绝全部连接。勾选后写入 ~/.opsxdb/mcp.json
+ {policyPath ? `(${policyPath})` : ""}。
+
暂无已保存连接。请先在侧栏新建连接。
+ ) : ( +写入位置:{snippet.path}
+
+ {snippet.json}
+
+
+