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

+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]
}