feat(mcp): 设置 → MCP 子页 + 策略 API + 官网/文档接入
借鉴 DBX:安装检测(Node/npm/包/Go 二进制)、只读与 allow_dangerous、 连接白名单、多客户端配置片段;GET/PUT /api/ai/mcp-policy、/mcp-install; 文档 /docs/mcp 与落地页 AI/MCP 块;对照 docs/16 勾选设置项、列仍未完清单。
This commit is contained in:
1 parent
5416e86379
commit
1513f4bc2d
20 files changed
+1567
-98
No files matched your search
@@ -0,0 +1,275 @@
|
||||
package aitools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const npmPackageName = "@rlwj-lab/opsxdb-mcp"
|
||||
|
||||
// MCPInstallInfo 设置 → MCP 页的安装/环境检测结果。
|
||||
type MCPInstallInfo struct {
|
||||
Status string `json:"status"` // ready | not_installed | partial
|
||||
Installed bool `json:"installed"`
|
||||
CurrentVersion string `json:"currentVersion,omitempty"`
|
||||
LatestVersion string `json:"latestVersion,omitempty"`
|
||||
NodeVersion string `json:"nodeVersion,omitempty"`
|
||||
NpmAvailable bool `json:"npmAvailable"`
|
||||
NpmVersion string `json:"npmVersion,omitempty"`
|
||||
NpmPackageInstalled bool `json:"npmPackageInstalled"`
|
||||
NpmPackageVersion string `json:"npmPackageVersion,omitempty"`
|
||||
GoBinaryFound bool `json:"goBinaryFound"`
|
||||
GoBinaryPath string `json:"goBinaryPath,omitempty"`
|
||||
InstallCommand string `json:"installCommand"`
|
||||
NpxCommand string `json:"npxCommand"`
|
||||
PolicyPath string `json:"policyPath,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
// ProbeMCPInstall 检测 Node/npm、全局 npm 包、Go 二进制与 registry 最新版。
|
||||
func ProbeMCPInstall() MCPInstallInfo {
|
||||
info := MCPInstallInfo{
|
||||
Status: "not_installed",
|
||||
InstallCommand: "npm install -g " + npmPackageName + "@latest",
|
||||
NpxCommand: "npx -y " + npmPackageName,
|
||||
}
|
||||
if p, err := PolicyPath(); err == nil {
|
||||
info.PolicyPath = p
|
||||
}
|
||||
|
||||
node, nodeVer := findToolVersion("node", "--version")
|
||||
npm, npmVer := findToolVersion("npm", "--version")
|
||||
info.NodeVersion = strings.TrimPrefix(strings.TrimSpace(nodeVer), "v")
|
||||
if node != "" && info.NodeVersion != "" {
|
||||
info.NodeVersion = "v" + strings.TrimPrefix(info.NodeVersion, "v")
|
||||
}
|
||||
info.NpmAvailable = npm != ""
|
||||
info.NpmVersion = strings.TrimSpace(npmVer)
|
||||
|
||||
if bin := resolveGoMCPBinary(); bin != "" {
|
||||
info.GoBinaryFound = true
|
||||
info.GoBinaryPath = bin
|
||||
}
|
||||
|
||||
if npm != "" {
|
||||
ver := npmGlobalPackageVersion(npm, npmPackageName)
|
||||
if ver != "" {
|
||||
info.NpmPackageInstalled = true
|
||||
info.NpmPackageVersion = ver
|
||||
info.CurrentVersion = ver
|
||||
}
|
||||
}
|
||||
if info.CurrentVersion == "" && info.GoBinaryFound {
|
||||
info.CurrentVersion = "native"
|
||||
}
|
||||
|
||||
info.LatestVersion = fetchLatestNpmVersion(npmPackageName)
|
||||
|
||||
switch {
|
||||
case info.GoBinaryFound && (info.NpmPackageInstalled || info.NpmAvailable):
|
||||
info.Status = "ready"
|
||||
info.Installed = true
|
||||
if !info.NpmPackageInstalled {
|
||||
info.Hint = "已找到 Go 二进制;建议再 npm i -g,或用 npx + OPSXDB_MCP_BIN。"
|
||||
}
|
||||
case info.GoBinaryFound:
|
||||
info.Status = "partial"
|
||||
info.Installed = true
|
||||
info.Hint = "已找到 Go 二进制,但未检测到 Node/npm。客户端可用绝对路径启动二进制。"
|
||||
case info.NpmPackageInstalled:
|
||||
info.Status = "partial"
|
||||
info.Installed = false
|
||||
info.Hint = "npm 包已装,但未找到 Go 二进制。请 go build 后设置 OPSXDB_MCP_BIN。"
|
||||
case info.NpmAvailable:
|
||||
info.Status = "not_installed"
|
||||
info.Hint = "检测到 Node/npm,尚未安装 MCP 包或 Go 二进制。"
|
||||
default:
|
||||
info.Status = "not_installed"
|
||||
info.Hint = "未检测到 Node.js。请安装 Node 18+,或直接用 Go 二进制绝对路径配置客户端。"
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func findToolVersion(name string, versionArg string) (bin, version string) {
|
||||
bin = lookPathAugmented(name)
|
||||
if bin == "" {
|
||||
return "", ""
|
||||
}
|
||||
out, err := exec.Command(bin, versionArg).CombinedOutput()
|
||||
if err != nil {
|
||||
return bin, ""
|
||||
}
|
||||
return bin, strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func lookPathAugmented(name string) string {
|
||||
if p, err := exec.LookPath(name); err == nil {
|
||||
return p
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
var extras []string
|
||||
if home != "" {
|
||||
nvm := filepath.Join(home, ".nvm", "versions", "node")
|
||||
if ents, err := os.ReadDir(nvm); err == nil {
|
||||
for _, e := range ents {
|
||||
if e.IsDir() {
|
||||
extras = append(extras, filepath.Join(nvm, e.Name(), "bin"))
|
||||
}
|
||||
}
|
||||
}
|
||||
extras = append(extras,
|
||||
filepath.Join(home, ".local", "bin"),
|
||||
filepath.Join(home, "bin"),
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
)
|
||||
}
|
||||
pathEnv := os.Getenv("PATH")
|
||||
aug := strings.Join(extras, string(os.PathListSeparator))
|
||||
if pathEnv != "" {
|
||||
aug = aug + string(os.PathListSeparator) + pathEnv
|
||||
}
|
||||
cmd := exec.Command(name, "--version")
|
||||
cmd.Env = append(os.Environ(), "PATH="+aug)
|
||||
// Prefer LookPath with augmented PATH
|
||||
for _, dir := range extras {
|
||||
cand := filepath.Join(dir, name)
|
||||
if runtime.GOOS == "windows" {
|
||||
cand += ".exe"
|
||||
}
|
||||
if st, err := os.Stat(cand); err == nil && !st.IsDir() {
|
||||
return cand
|
||||
}
|
||||
}
|
||||
if p, err := exec.LookPath(name); err == nil {
|
||||
return p
|
||||
}
|
||||
_ = cmd
|
||||
return ""
|
||||
}
|
||||
|
||||
func npmGlobalPackageVersion(npmBin, pkg string) string {
|
||||
out, err := exec.Command(npmBin, "list", "-g", pkg, "--depth=0", "--json").CombinedOutput()
|
||||
if err != nil && len(out) == 0 {
|
||||
return ""
|
||||
}
|
||||
var raw struct {
|
||||
Dependencies map[string]struct {
|
||||
Version string `json:"version"`
|
||||
} `json:"dependencies"`
|
||||
}
|
||||
if json.Unmarshal(out, &raw) != nil {
|
||||
return ""
|
||||
}
|
||||
if d, ok := raw.Dependencies[pkg]; ok {
|
||||
return d.Version
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fetchLatestNpmVersion(pkg string) string {
|
||||
client := &http.Client{Timeout: 4 * time.Second}
|
||||
url := "https://registry.npmjs.org/" + pkg + "/latest"
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != 200 {
|
||||
return ""
|
||||
}
|
||||
var raw struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if json.NewDecoder(res.Body).Decode(&raw) != nil {
|
||||
return ""
|
||||
}
|
||||
return raw.Version
|
||||
}
|
||||
|
||||
func resolveGoMCPBinary() string {
|
||||
if env := strings.TrimSpace(os.Getenv("OPSXDB_MCP_BIN")); env != "" {
|
||||
if isLikelyNativeBinary(env) {
|
||||
return env
|
||||
}
|
||||
}
|
||||
if p, err := exec.LookPath("opsxdb-mcp"); err == nil && isLikelyNativeBinary(p) {
|
||||
return p
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
cands := []string{}
|
||||
if home != "" {
|
||||
cands = append(cands,
|
||||
filepath.Join(home, "devops", "opsxdb", "bin", "opsxdb-mcp"),
|
||||
filepath.Join(home, ".opsxdb", "bin", "opsxdb-mcp"),
|
||||
filepath.Join(home, ".local", "bin", "opsxdb-mcp"),
|
||||
)
|
||||
}
|
||||
// Relative to running server binary (src-tauri/binaries or server/)
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
dir := filepath.Dir(exe)
|
||||
cands = append(cands,
|
||||
filepath.Join(dir, "opsxdb-mcp"),
|
||||
filepath.Join(dir, "..", "opsxdb-mcp"),
|
||||
filepath.Join(dir, "..", "..", "bin", "opsxdb-mcp"),
|
||||
filepath.Join(dir, "..", "..", "..", "bin", "opsxdb-mcp"),
|
||||
)
|
||||
}
|
||||
cands = append(cands, "/usr/local/bin/opsxdb-mcp", "/opt/homebrew/bin/opsxdb-mcp")
|
||||
for _, c := range cands {
|
||||
if isLikelyNativeBinary(c) {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isLikelyNativeBinary(path string) bool {
|
||||
st, err := os.Stat(path)
|
||||
if err != nil || st.IsDir() {
|
||||
return false
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
buf := make([]byte, 8)
|
||||
n, _ := f.Read(buf)
|
||||
if n < 2 {
|
||||
return false
|
||||
}
|
||||
// shebang → script
|
||||
if buf[0] == '#' && buf[1] == '!' {
|
||||
return false
|
||||
}
|
||||
if n >= 4 && buf[0] == 0x7f && buf[1] == 'E' && buf[2] == 'L' && buf[3] == 'F' {
|
||||
return true
|
||||
}
|
||||
if buf[0] == 'M' && buf[1] == 'Z' {
|
||||
return true
|
||||
}
|
||||
if n >= 4 {
|
||||
be := uint32(buf[0])<<24 | uint32(buf[1])<<16 | uint32(buf[2])<<8 | uint32(buf[3])
|
||||
le := uint32(buf[3])<<24 | uint32(buf[2])<<16 | uint32(buf[1])<<8 | uint32(buf[0])
|
||||
macho := map[uint32]bool{
|
||||
0xfeedfacf: true, 0xcffaedfe: true, 0xfeedface: true, 0xcefaedfe: true,
|
||||
0xcafebabe: true, 0xbebafeca: true, 0xcafebabf: true, 0xbfbafeca: true,
|
||||
}
|
||||
if macho[be] || macho[le] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package aitools
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestProbeMCPInstallSmoke(t *testing.T) {
|
||||
info := ProbeMCPInstall()
|
||||
if info.InstallCommand == "" {
|
||||
t.Fatal("empty install command")
|
||||
}
|
||||
if info.Status == "" {
|
||||
t.Fatal("empty status")
|
||||
}
|
||||
t.Logf("status=%s node=%s npm=%v go=%v path=%s latest=%s",
|
||||
info.Status, info.NodeVersion, info.NpmAvailable, info.GoBinaryFound, info.GoBinaryPath, info.LatestVersion)
|
||||
}
|
||||
@@ -18,8 +18,11 @@ const (
|
||||
type Policy struct {
|
||||
AllowedConnectionIDs []string `json:"allowed_connection_ids"`
|
||||
Mode string `json:"mode"` // read_only | read_write
|
||||
DefaultLimit int `json:"default_limit,omitempty"`
|
||||
MaxLimit int `json:"max_limit,omitempty"`
|
||||
// AllowDangerous 为 true 时,在 mode=read_write 且已确认后允许 DROP/TRUNCATE/ALTER 等危险 SQL。
|
||||
// 默认 false:普通 INSERT/UPDATE/DELETE(有 WHERE)仍可写,DDL/无 WHERE 等仍拒。
|
||||
AllowDangerous bool `json:"allow_dangerous,omitempty"`
|
||||
DefaultLimit int `json:"default_limit,omitempty"`
|
||||
MaxLimit int `json:"max_limit,omitempty"`
|
||||
}
|
||||
|
||||
func DefaultPolicy() Policy {
|
||||
@@ -114,3 +117,62 @@ func (p Policy) ClampLimit(n int) int {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// SavePolicy 原子写入 ~/.opsxdb/mcp.json(先写临时文件再 rename)。
|
||||
func SavePolicy(p Policy) error {
|
||||
p.Mode = strings.ToLower(strings.TrimSpace(p.Mode))
|
||||
if p.Mode != ModeReadWrite {
|
||||
p.Mode = ModeReadOnly
|
||||
}
|
||||
if p.DefaultLimit <= 0 {
|
||||
p.DefaultLimit = 100
|
||||
}
|
||||
if p.MaxLimit <= 0 {
|
||||
p.MaxLimit = 500
|
||||
}
|
||||
if p.DefaultLimit > p.MaxLimit {
|
||||
p.DefaultLimit = p.MaxLimit
|
||||
}
|
||||
if p.AllowedConnectionIDs == nil {
|
||||
p.AllowedConnectionIDs = []string{}
|
||||
}
|
||||
// 去空白、去重保序
|
||||
seen := map[string]struct{}{}
|
||||
clean := make([]string, 0, len(p.AllowedConnectionIDs))
|
||||
for _, id := range p.AllowedConnectionIDs {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
clean = append(clean, id)
|
||||
}
|
||||
p.AllowedConnectionIDs = clean
|
||||
|
||||
path, err := PolicyPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := json.MarshalIndent(p, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b = append(b, '\n')
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// AllowsDangerousDDL 危险 SQL(DROP/TRUNCATE/…)是否在确认后放行。
|
||||
func (p Policy) AllowsDangerousDDL() bool {
|
||||
return p.AllowDangerous && p.WritesEnabled()
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package aitools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSaveLoadPolicyRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("HOME", dir)
|
||||
// PolicyPath uses UserHomeDir → ~/.opsxdb/mcp.json
|
||||
p := Policy{
|
||||
AllowedConnectionIDs: []string{" a ", "b", "a", ""},
|
||||
Mode: "READ_WRITE",
|
||||
AllowDangerous: true,
|
||||
DefaultLimit: 50,
|
||||
MaxLimit: 200,
|
||||
}
|
||||
if err := SavePolicy(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, ".opsxdb", "mcp.json")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := LoadPolicy()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Mode != ModeReadWrite {
|
||||
t.Fatalf("mode=%s", got.Mode)
|
||||
}
|
||||
if !got.AllowDangerous {
|
||||
t.Fatal("want allow_dangerous")
|
||||
}
|
||||
if len(got.AllowedConnectionIDs) != 2 || got.AllowedConnectionIDs[0] != "a" || got.AllowedConnectionIDs[1] != "b" {
|
||||
t.Fatalf("ids=%v", got.AllowedConnectionIDs)
|
||||
}
|
||||
if got.DefaultLimit != 50 || got.MaxLimit != 200 {
|
||||
t.Fatalf("limits %d/%d", got.DefaultLimit, got.MaxLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDangerousSQLRequiresAllowFlag(t *testing.T) {
|
||||
rt := NewRuntime(Policy{
|
||||
AllowedConnectionIDs: []string{"c1"},
|
||||
Mode: ModeReadWrite,
|
||||
AllowDangerous: false,
|
||||
})
|
||||
rt.FindConn = func(id string) (*SavedConnection, error) {
|
||||
return &SavedConnection{ID: id, Name: "c1", Driver: "mysql", Host: "127.0.0.1", Port: 3306}, nil
|
||||
}
|
||||
rt.Audit = func(AuditEntry) {}
|
||||
rt.AllowConfirmedWrite = true
|
||||
_, err := rt.Call(context.Background(), ToolExecuteQuery, map[string]any{
|
||||
"connection_id": "c1",
|
||||
"sql": "DROP TABLE users",
|
||||
"confirm_danger": true,
|
||||
})
|
||||
if err == nil || !contains(err.Error(), "dangerous sql disabled") {
|
||||
t.Fatalf("want dangerous disabled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool { return strings.Contains(s, sub) }
|
||||
@@ -401,6 +401,10 @@ func (r *Runtime) executeQuery(ctx context.Context, args map[string]any) (any, e
|
||||
if !r.Policy.WritesEnabled() {
|
||||
return fail(readReason)
|
||||
}
|
||||
// 危险 DDL(DROP/TRUNCATE/…)另需 allow_dangerous;普通 INSERT/UPDATE/DELETE 不受此开关限制。
|
||||
if hits := db.CheckDangerousSQL("", sqlText); len(hits) > 0 && !r.Policy.AllowDangerous {
|
||||
return fail("dangerous sql disabled: enable allow_dangerous in Settings → MCP (or mcp.json); hits: " + strings.Join(hits, "; "))
|
||||
}
|
||||
// P1:可写已开启,但无确认令牌则返回 needConfirm(永不静默写)。
|
||||
if !confirmed {
|
||||
r.audit(AuditEntry{Tool: ToolExecuteQuery, ConnectionID: id, OK: false, Error: "needConfirm", Summary: truncate(sqlText, 80)})
|
||||
|
||||
@@ -287,7 +287,7 @@ func TestExecuteQueryRejectConfirmWhenAgentFlag(t *testing.T) {
|
||||
}
|
||||
rt.Audit = func(AuditEntry) {}
|
||||
out, err := rt.Call(context.Background(), ToolExecuteQuery, map[string]any{
|
||||
"connection_id": "c1", "sql": "UPDATE t SET a=1", "confirm_danger": true,
|
||||
"connection_id": "c1", "sql": "UPDATE t SET a=1 WHERE id=1", "confirm_danger": true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -15,6 +15,7 @@ type MCPStatus struct {
|
||||
Mode string `json:"mode"`
|
||||
WhitelistCount int `json:"whitelistCount"`
|
||||
WritesEnabled bool `json:"writesEnabled"`
|
||||
AllowDangerous bool `json:"allowDangerous"`
|
||||
Active bool `json:"active"` // 近 5 分钟有 source=mcp 审计
|
||||
Recent []AuditEntry `json:"recent,omitempty"`
|
||||
PolicyPath string `json:"policyPath,omitempty"`
|
||||
@@ -37,6 +38,7 @@ func GetMCPStatus() MCPStatus {
|
||||
}
|
||||
st.WhitelistCount = len(p.AllowedConnectionIDs)
|
||||
st.WritesEnabled = p.WritesEnabled()
|
||||
st.AllowDangerous = p.AllowDangerous
|
||||
st.Configured = st.Configured || st.WhitelistCount > 0
|
||||
}
|
||||
if ap, err := AuditPath(); err == nil {
|
||||
|
||||
Reference in new issue
Block a user