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
+1567 -98

No files matched your search

+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)
}
+64 -2
View File
@@ -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()
}
+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() {
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)})
+1 -1
View File
@@ -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)
+2
View File
@@ -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 {