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,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]
|
||||
}
|
||||
Reference in new issue
Block a user