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
}