feat(mcp): P0 只读 MCP tool runtime + opsxdb-mcp stdio
- aitools 共用 runtime:list/describe/schema/query/redis(纯只读,无写确认) - 白名单 ~/.opsxdb/mcp.json;审计 mcp-audit.jsonl;复用 danger.go - 桌面 connections 双写 ~/.opsxdb/connections.json(ox1. 解码) - Cursor 配置示例与任务书验收勾选更新;写操作与 Agent UI 留 P1
This commit is contained in:
1 parent
5c17d32647
commit
0b68c8344d
19 files changed
+1989
-79
No files matched your search
@@ -0,0 +1,109 @@
|
||||
// opsxdb-mcp:stdio MCP Server,暴露只读数据库工具(P0)。 // Logan.Li
|
||||
//
|
||||
// 配置:
|
||||
// ~/.opsxdb/mcp.json — allowed_connection_ids;mode(P0 执行层强制只读)
|
||||
// ~/.opsxdb/connections.json — 桌面双写的连接列表(密码 ox1. 混淆)
|
||||
// ~/.opsxdb/logs/mcp-audit.jsonl
|
||||
//
|
||||
// Cursor 示例见 docs/mcp-cursor.example.json
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
|
||||
"opsxdb/server/internal/aitools"
|
||||
)
|
||||
|
||||
func main() {
|
||||
rt, err := aitools.DefaultRuntime()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "opsxdb-mcp: load policy: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
s := server.NewMCPServer(
|
||||
"opsxdb",
|
||||
"0.1.0",
|
||||
server.WithToolCapabilities(true),
|
||||
)
|
||||
|
||||
register := func(name, desc string, props map[string]any, required []string) {
|
||||
schema := map[string]any{
|
||||
"type": "object",
|
||||
"properties": props,
|
||||
}
|
||||
if len(required) > 0 {
|
||||
schema["required"] = required
|
||||
}
|
||||
raw, _ := json.Marshal(schema)
|
||||
t := mcp.NewToolWithRawSchema(name, desc, raw)
|
||||
toolName := name
|
||||
s.AddTool(t, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args := req.GetArguments()
|
||||
out, err := rt.Call(ctx, toolName, args)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
return mcp.NewToolResultText(aitools.ResultJSON(out)), nil
|
||||
})
|
||||
}
|
||||
|
||||
connID := map[string]any{"type": "string", "description": "Saved connection id from opsxdb_list_connections"}
|
||||
database := map[string]any{"type": "string", "description": "Database / schema / Redis DB index"}
|
||||
|
||||
register(aitools.ToolListConnections,
|
||||
"List whitelisted OpsxDB connections (id/name/driver/host/port/database; never passwords).",
|
||||
map[string]any{}, nil)
|
||||
|
||||
register(aitools.ToolListDatabases,
|
||||
"List databases for a connection.",
|
||||
map[string]any{"connection_id": connID}, []string{"connection_id"})
|
||||
|
||||
register(aitools.ToolListTables,
|
||||
"List tables/views (or engine equivalents) in a database.",
|
||||
map[string]any{"connection_id": connID, "database": database}, []string{"connection_id"})
|
||||
|
||||
register(aitools.ToolDescribeTable,
|
||||
"Describe columns and metadata for a table.",
|
||||
map[string]any{
|
||||
"connection_id": connID,
|
||||
"database": database,
|
||||
"table": map[string]any{"type": "string", "description": "Table name"},
|
||||
}, []string{"connection_id", "table"})
|
||||
|
||||
register(aitools.ToolGetSchemaContext,
|
||||
"Compact schema text for LLM context (capped ~4k chars).",
|
||||
map[string]any{
|
||||
"connection_id": connID,
|
||||
"database": database,
|
||||
"max_tables": map[string]any{"type": "integer", "description": "Max tables to include (default 40)"},
|
||||
}, []string{"connection_id"})
|
||||
|
||||
register(aitools.ToolExecuteQuery,
|
||||
"Execute a READ-ONLY SQL query (SELECT/SHOW/EXPLAIN/WITH…SELECT). Writes and DDL are rejected in P0. Default limit 100, max 500.",
|
||||
map[string]any{
|
||||
"connection_id": connID,
|
||||
"sql": map[string]any{"type": "string", "description": "Read-only SQL"},
|
||||
"database": database,
|
||||
"limit": map[string]any{"type": "integer", "description": "Row limit (default 100, max 500)"},
|
||||
}, []string{"connection_id", "sql"})
|
||||
|
||||
register(aitools.ToolExecuteRedisCommand,
|
||||
"Execute a READ-ONLY Redis command (GET/SCAN/TYPE/TTL/…). FLUSH*/KEYS and writes are rejected in P0.",
|
||||
map[string]any{
|
||||
"connection_id": connID,
|
||||
"command": map[string]any{"type": "string", "description": "Single Redis command line"},
|
||||
"database": database,
|
||||
}, []string{"connection_id", "command"})
|
||||
|
||||
if err := server.ServeStdio(s); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "opsxdb-mcp: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -3,9 +3,11 @@ module opsxdb/server
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/alicebob/miniredis/v2 v2.39.0
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-sql-driver/mysql v1.8.1
|
||||
github.com/jackc/pgx/v5 v5.7.2
|
||||
github.com/mark3labs/mcp-go v0.43.2
|
||||
github.com/microsoft/go-mssqldb v1.8.0
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
github.com/sijms/go-ora/v2 v2.8.22
|
||||
@@ -16,7 +18,8 @@ require (
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/alicebob/miniredis/v2 v2.39.0 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
@@ -33,13 +36,14 @@ require (
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/invopop/jsonschema v0.13.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
@@ -47,9 +51,12 @@ require (
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rogpeppe/go-internal v1.10.0 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
|
||||
+19
-3
@@ -14,10 +14,14 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mx
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/alicebob/miniredis/v2 v2.39.0 h1:M7WbmV5BmV56L8KTG0rw6vEQ+woTOghpDgin2xv4A0g=
|
||||
github.com/alicebob/miniredis/v2 v2.39.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
@@ -28,7 +32,6 @@ github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -36,6 +39,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
@@ -67,6 +72,8 @@ github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlG
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
|
||||
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -75,6 +82,7 @@ github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
|
||||
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
@@ -87,6 +95,10 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I=
|
||||
github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/microsoft/go-mssqldb v1.8.0 h1:7cyZ/AT7ycDsEoWPIXibd+aVKFtteUNhDGf3aobP+tw=
|
||||
@@ -102,18 +114,18 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/sijms/go-ora/v2 v2.8.22 h1:3ABgRzVKxS439cEgSLjFKutIwOyhnyi4oOSBywEdOlU=
|
||||
github.com/sijms/go-ora/v2 v2.8.22/go.mod h1:QgFInVi3ZWyqAiJwzBQA+nbKYKH77tdp1PYoCqhR2dU=
|
||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -130,6 +142,10 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
||||
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package aitools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuditEntry MCP/Agent 工具审计;不含密码与大结果全文。 // Logan.Li
|
||||
type AuditEntry struct {
|
||||
Time string `json:"time"`
|
||||
Tool string `json:"tool"`
|
||||
ConnectionID string `json:"connection_id,omitempty"`
|
||||
Mode string `json:"mode"`
|
||||
OK bool `json:"ok"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
var auditMu sync.Mutex
|
||||
|
||||
func AuditPath() (string, error) {
|
||||
dir, err := opsxdbDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "logs", "mcp-audit.jsonl"), nil
|
||||
}
|
||||
|
||||
// AppendAudit 追加一行 JSONL;失败静默(不影响工具主路径)。
|
||||
func AppendAudit(e AuditEntry) {
|
||||
if e.Time == "" {
|
||||
e.Time = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
path, err := AuditPath()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
auditMu.Lock()
|
||||
defer auditMu.Unlock()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return
|
||||
}
|
||||
b, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
_, _ = f.Write(append(b, '\n'))
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package aitools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"opsxdb/server/internal/db"
|
||||
)
|
||||
|
||||
// SavedConnection 与桌面 store SavedConnection JSON 对齐(磁盘形态,密码可为 ox1. 混淆)。 // Logan.Li
|
||||
type SavedConnection struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Driver string `json:"driver"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
Database string `json:"database"`
|
||||
Service string `json:"service,omitempty"`
|
||||
SSL bool `json:"ssl,omitempty"`
|
||||
SSLMode string `json:"sslMode,omitempty"`
|
||||
SSLCA string `json:"sslCA,omitempty"`
|
||||
SSLCert string `json:"sslCert,omitempty"`
|
||||
SSLKey string `json:"sslKey,omitempty"`
|
||||
RedisMode string `json:"redisMode,omitempty"`
|
||||
RedisNodes string `json:"redisNodes,omitempty"`
|
||||
MasterName string `json:"masterName,omitempty"`
|
||||
SentinelPassword string `json:"sentinelPassword,omitempty"`
|
||||
SSHEnabled bool `json:"sshEnabled,omitempty"`
|
||||
SSHHost string `json:"sshHost,omitempty"`
|
||||
SSHPort int `json:"sshPort,omitempty"`
|
||||
SSHUser string `json:"sshUser,omitempty"`
|
||||
SSHAuth string `json:"sshAuth,omitempty"`
|
||||
SSHPassword string `json:"sshPassword,omitempty"`
|
||||
SSHPrivateKey string `json:"sshPrivateKey,omitempty"`
|
||||
SSHPassphrase string `json:"sshPassphrase,omitempty"`
|
||||
SavePassword *bool `json:"savePassword,omitempty"`
|
||||
}
|
||||
|
||||
// ConnectionSummary 列表对外字段,永不含密码。
|
||||
type ConnectionSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Driver string `json:"driver"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Database string `json:"database"`
|
||||
}
|
||||
|
||||
func ConnectionsPath() (string, error) {
|
||||
dir, err := opsxdbDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "connections.json"), nil
|
||||
}
|
||||
|
||||
// LoadConnections 读 ~/.opsxdb/connections.json。
|
||||
func LoadConnections() ([]SavedConnection, error) {
|
||||
path, err := ConnectionsPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var list []SavedConnection
|
||||
if err := json.Unmarshal(b, &list); err != nil {
|
||||
return nil, fmt.Errorf("connections.json: %w", err)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// SaveConnections 写 ~/.opsxdb/connections.json(0600);供桌面双写 API 调用。
|
||||
func SaveConnections(list []SavedConnection) error {
|
||||
path, err := ConnectionsPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := json.MarshalIndent(list, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, b, 0600)
|
||||
}
|
||||
|
||||
func FindConnection(id string) (*SavedConnection, error) {
|
||||
list, err := LoadConnections()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range list {
|
||||
if list[i].ID == id {
|
||||
c := list[i]
|
||||
return &c, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("connection not found: %s", id)
|
||||
}
|
||||
|
||||
// ToConnReq 映射为 db.ConnReq(解码 ox1. 密码)。
|
||||
func (c SavedConnection) ToConnReq() db.ConnReq {
|
||||
sslOn := c.SSL || (c.SSLMode != "" && c.SSLMode != "disable")
|
||||
mode := c.SSLMode
|
||||
if sslOn {
|
||||
if mode == "" || mode == "disable" {
|
||||
mode = "require"
|
||||
}
|
||||
} else {
|
||||
mode = "disable"
|
||||
}
|
||||
return db.ConnReq{
|
||||
Driver: c.Driver,
|
||||
Host: c.Host,
|
||||
Port: c.Port,
|
||||
User: c.User,
|
||||
Password: DecodeSecret(c.Password),
|
||||
Database: c.Database,
|
||||
Service: c.Service,
|
||||
SSL: sslOn,
|
||||
SSLMode: mode,
|
||||
SSLCA: c.SSLCA,
|
||||
SSLCert: c.SSLCert,
|
||||
SSLKey: c.SSLKey,
|
||||
RedisMode: orDefault(c.RedisMode, "standalone"),
|
||||
RedisNodes: c.RedisNodes,
|
||||
MasterName: c.MasterName,
|
||||
SentinelPassword: DecodeSecret(c.SentinelPassword),
|
||||
SSHEnabled: c.SSHEnabled,
|
||||
SSHHost: c.SSHHost,
|
||||
SSHPort: c.SSHPort,
|
||||
SSHUser: c.SSHUser,
|
||||
SSHAuth: c.SSHAuth,
|
||||
SSHPassword: DecodeSecret(c.SSHPassword),
|
||||
SSHPrivateKey: c.SSHPrivateKey,
|
||||
SSHPassphrase: DecodeSecret(c.SSHPassphrase),
|
||||
}
|
||||
}
|
||||
|
||||
func (c SavedConnection) Summary() ConnectionSummary {
|
||||
return ConnectionSummary{
|
||||
ID: c.ID,
|
||||
Name: c.Name,
|
||||
Driver: c.Driver,
|
||||
Host: c.Host,
|
||||
Port: c.Port,
|
||||
Database: c.Database,
|
||||
}
|
||||
}
|
||||
|
||||
func orDefault(s, def string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package aitools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Mode P0 仅支持 read_only;read_write 留给 P1(写 + 确认)。 // Logan.Li
|
||||
const (
|
||||
ModeReadOnly = "read_only"
|
||||
ModeReadWrite = "read_write" // P1 才启用写路径;P0 加载后仍强制只读执行
|
||||
)
|
||||
|
||||
// Policy ~/.opsxdb/mcp.json
|
||||
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"`
|
||||
}
|
||||
|
||||
func DefaultPolicy() Policy {
|
||||
return Policy{
|
||||
AllowedConnectionIDs: nil,
|
||||
Mode: ModeReadOnly,
|
||||
DefaultLimit: 100,
|
||||
MaxLimit: 500,
|
||||
}
|
||||
}
|
||||
|
||||
func opsxdbDir() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".opsxdb"), nil
|
||||
}
|
||||
|
||||
func PolicyPath() (string, error) {
|
||||
dir, err := opsxdbDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "mcp.json"), nil
|
||||
}
|
||||
|
||||
// LoadPolicy 读 mcp.json;缺省文件返回 DefaultPolicy(白名单空=拒绝全部连接)。
|
||||
func LoadPolicy() (Policy, error) {
|
||||
p := DefaultPolicy()
|
||||
path, err := PolicyPath()
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return p, nil
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
if err := json.Unmarshal(b, &p); err != nil {
|
||||
return DefaultPolicy(), fmt.Errorf("mcp.json: %w", err)
|
||||
}
|
||||
p.Mode = strings.ToLower(strings.TrimSpace(p.Mode))
|
||||
if p.Mode == "" {
|
||||
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
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (p Policy) IsReadOnly() bool {
|
||||
// P0:即使配置写了 read_write,执行层仍按只读拦截;此 helper 表示「策略意图」。
|
||||
return p.Mode != ModeReadWrite
|
||||
}
|
||||
|
||||
func (p Policy) AllowConnection(id string) error {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return fmt.Errorf("connection_id required")
|
||||
}
|
||||
if len(p.AllowedConnectionIDs) == 0 {
|
||||
return fmt.Errorf("connection not allowed: whitelist empty (configure allowed_connection_ids in ~/.opsxdb/mcp.json)")
|
||||
}
|
||||
for _, a := range p.AllowedConnectionIDs {
|
||||
if a == id {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("connection not allowed: %s not in allowed_connection_ids", id)
|
||||
}
|
||||
|
||||
func (p Policy) ClampLimit(n int) int {
|
||||
if n <= 0 {
|
||||
n = p.DefaultLimit
|
||||
}
|
||||
if n > p.MaxLimit {
|
||||
n = p.MaxLimit
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package aitools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"opsxdb/server/internal/db"
|
||||
)
|
||||
|
||||
// redisReadCmds P0 纯只读允许的 Redis 命令(对齐现有 execRedis 读路径)。 // Logan.Li
|
||||
var redisReadCmds = map[string]bool{
|
||||
"PING": true, "GET": true, "MGET": true, "TYPE": true, "TTL": true, "PTTL": true,
|
||||
"EXISTS": true, "SCAN": true, "HGET": true, "HGETALL": true, "HLEN": true, "HKEYS": true, "HVALS": true,
|
||||
"LRANGE": true, "LLEN": true, "LINDEX": true,
|
||||
"SMEMBERS": true, "SCARD": true, "SISMEMBER": true,
|
||||
"ZRANGE": true, "ZCARD": true, "ZSCORE": true, "ZRANK": true,
|
||||
"STRLEN": true, "DBSIZE": true, "INFO": true, "OBJECT": true, "MEMORY": true,
|
||||
}
|
||||
|
||||
// redisDeniedAlways P0 明确拒绝(即便将来 read_write 也需确认;此处直接拒)。
|
||||
var redisDeniedAlways = map[string]bool{
|
||||
"FLUSHALL": true, "FLUSHDB": true, "KEYS": true, "CONFIG": true,
|
||||
}
|
||||
|
||||
// IsReadOnlySQL P0:仅允许读类 SQL。INSERT/UPDATE/DELETE/DDL 一律 false。
|
||||
// WITH 仅当体中无写动词且含 SELECT 时放行。复用 CheckDangerousSQL 作二次拦截。
|
||||
func IsReadOnlySQL(sqlText string) (bool, string) {
|
||||
s := strings.TrimSpace(stripLeadComments(sqlText))
|
||||
if s == "" {
|
||||
return false, "empty sql"
|
||||
}
|
||||
up := strings.ToUpper(s)
|
||||
// 去掉开头空白后的首词
|
||||
first := firstWord(up)
|
||||
switch first {
|
||||
case "SELECT", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "PRAGMA":
|
||||
// ok
|
||||
case "WITH":
|
||||
// WITH … SELECT 可读;WITH … INSERT/UPDATE/DELETE 拒
|
||||
if hasWriteVerb(up) {
|
||||
return false, "WITH statement contains write verb (P0 read-only)"
|
||||
}
|
||||
if !strings.Contains(up, "SELECT") {
|
||||
return false, "WITH without SELECT is not allowed in read-only mode"
|
||||
}
|
||||
default:
|
||||
return false, fmt.Sprintf("not a read-only statement (starts with %s); P0 MCP is read-only", first)
|
||||
}
|
||||
if hits := db.CheckDangerousSQL("", sqlText); len(hits) > 0 {
|
||||
return false, "dangerous sql blocked: " + strings.Join(hits, "; ")
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func hasWriteVerb(up string) bool {
|
||||
for _, v := range []string{" INSERT ", " UPDATE ", " DELETE ", " DROP ", " ALTER ", " CREATE ", " TRUNCATE ", " REPLACE ", " MERGE ", " GRANT ", " REVOKE ", " CALL ", " EXEC ", " EXECUTE "} {
|
||||
if strings.Contains(" "+up+" ", v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 行首写动词
|
||||
for _, v := range []string{"INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "TRUNCATE", "REPLACE", "MERGE"} {
|
||||
if strings.HasPrefix(up, v+" ") || up == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func firstWord(up string) string {
|
||||
up = strings.TrimSpace(up)
|
||||
i := 0
|
||||
for i < len(up) && (unicode.IsLetter(rune(up[i])) || up[i] == '_') {
|
||||
i++
|
||||
}
|
||||
if i == 0 {
|
||||
return ""
|
||||
}
|
||||
return up[:i]
|
||||
}
|
||||
|
||||
func stripLeadComments(sql string) string {
|
||||
s := strings.TrimSpace(sql)
|
||||
for {
|
||||
if strings.HasPrefix(s, "--") {
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
s = strings.TrimSpace(s[i+1:])
|
||||
continue
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(s, "/*") {
|
||||
if i := strings.Index(s, "*/"); i >= 0 {
|
||||
s = strings.TrimSpace(s[i+2:])
|
||||
continue
|
||||
}
|
||||
return ""
|
||||
}
|
||||
break
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// CheckRedisReadOnly P0:仅白名单读命令;FLUSH*/KEYS/CONFIG 等一律拒。
|
||||
func CheckRedisReadOnly(commandLine string) (cmd string, err error) {
|
||||
fields := strings.Fields(strings.TrimSpace(commandLine))
|
||||
if len(fields) == 0 {
|
||||
return "", fmt.Errorf("empty redis command")
|
||||
}
|
||||
cmd = strings.ToUpper(fields[0])
|
||||
if redisDeniedAlways[cmd] {
|
||||
return cmd, fmt.Errorf("redis command denied in P0 read-only: %s", cmd)
|
||||
}
|
||||
if !redisReadCmds[cmd] {
|
||||
return cmd, fmt.Errorf("redis command not allowed in P0 read-only: %s (writes require P1)", cmd)
|
||||
}
|
||||
return cmd, nil
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package aitools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"opsxdb/server/internal/db"
|
||||
)
|
||||
|
||||
const (
|
||||
ToolListConnections = "opsxdb_list_connections"
|
||||
ToolListDatabases = "opsxdb_list_databases"
|
||||
ToolListTables = "opsxdb_list_tables"
|
||||
ToolDescribeTable = "opsxdb_describe_table"
|
||||
ToolGetSchemaContext = "opsxdb_get_schema_context"
|
||||
ToolExecuteQuery = "opsxdb_execute_query"
|
||||
ToolExecuteRedisCommand = "opsxdb_execute_redis_command"
|
||||
|
||||
schemaContextCap = 4000
|
||||
)
|
||||
|
||||
// ToolNames P0 注册表(顺序稳定,便于 tools/list 断言)。
|
||||
func ToolNames() []string {
|
||||
return []string{
|
||||
ToolListConnections,
|
||||
ToolListDatabases,
|
||||
ToolListTables,
|
||||
ToolDescribeTable,
|
||||
ToolGetSchemaContext,
|
||||
ToolExecuteQuery,
|
||||
ToolExecuteRedisCommand,
|
||||
}
|
||||
}
|
||||
|
||||
// Runtime 可被 MCP stdio 与将来侧栏 Agent 进程内共用。 // Logan.Li
|
||||
type Runtime struct {
|
||||
Policy Policy
|
||||
// LoadConns / FindConn 可注入,便于单测;nil 则读磁盘。
|
||||
LoadConns func() ([]SavedConnection, error)
|
||||
FindConn func(id string) (*SavedConnection, error)
|
||||
// ExecSQL 可注入;nil 则 db.ExecSQL。
|
||||
ExecSQL func(ctx context.Context, c db.ConnReq) db.ExecResult
|
||||
ListDB func(ctx context.Context, c db.ConnReq) ([]string, error)
|
||||
ListTbl func(ctx context.Context, c db.ConnReq) ([]string, error)
|
||||
ListCol func(ctx context.Context, c db.ConnReq) ([]db.ColumnInfo, error)
|
||||
Audit func(AuditEntry)
|
||||
}
|
||||
|
||||
func NewRuntime(policy Policy) *Runtime {
|
||||
return &Runtime{Policy: policy}
|
||||
}
|
||||
|
||||
func DefaultRuntime() (*Runtime, error) {
|
||||
p, err := LoadPolicy()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewRuntime(p), nil
|
||||
}
|
||||
|
||||
func (r *Runtime) audit(e AuditEntry) {
|
||||
if e.Mode == "" {
|
||||
e.Mode = r.Policy.Mode
|
||||
if e.Mode == "" {
|
||||
e.Mode = ModeReadOnly
|
||||
}
|
||||
}
|
||||
if r.Audit != nil {
|
||||
r.Audit(e)
|
||||
return
|
||||
}
|
||||
AppendAudit(e)
|
||||
}
|
||||
|
||||
func (r *Runtime) loadConns() ([]SavedConnection, error) {
|
||||
if r.LoadConns != nil {
|
||||
return r.LoadConns()
|
||||
}
|
||||
return LoadConnections()
|
||||
}
|
||||
|
||||
func (r *Runtime) findConn(id string) (*SavedConnection, error) {
|
||||
if r.FindConn != nil {
|
||||
return r.FindConn(id)
|
||||
}
|
||||
return FindConnection(id)
|
||||
}
|
||||
|
||||
func (r *Runtime) resolveAllowed(connectionID string) (*SavedConnection, error) {
|
||||
if err := r.Policy.AllowConnection(connectionID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, err := r.findConn(connectionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (r *Runtime) execSQL(ctx context.Context, c db.ConnReq) db.ExecResult {
|
||||
if r.ExecSQL != nil {
|
||||
return r.ExecSQL(ctx, c)
|
||||
}
|
||||
return db.ExecSQL(ctx, c)
|
||||
}
|
||||
|
||||
func (r *Runtime) listDB(ctx context.Context, c db.ConnReq) ([]string, error) {
|
||||
if r.ListDB != nil {
|
||||
return r.ListDB(ctx, c)
|
||||
}
|
||||
return db.ListDatabases(ctx, c)
|
||||
}
|
||||
|
||||
func (r *Runtime) listTbl(ctx context.Context, c db.ConnReq) ([]string, error) {
|
||||
if r.ListTbl != nil {
|
||||
return r.ListTbl(ctx, c)
|
||||
}
|
||||
return db.ListTables(ctx, c)
|
||||
}
|
||||
|
||||
func (r *Runtime) listCol(ctx context.Context, c db.ConnReq) ([]db.ColumnInfo, error) {
|
||||
if r.ListCol != nil {
|
||||
return r.ListCol(ctx, c)
|
||||
}
|
||||
return db.ListColumns(ctx, c)
|
||||
}
|
||||
|
||||
// Call 统一入口:MCP 与 Agent 都走这里。args 为 JSON object。
|
||||
func (r *Runtime) Call(ctx context.Context, tool string, args map[string]any) (any, error) {
|
||||
switch tool {
|
||||
case ToolListConnections:
|
||||
return r.listConnections(ctx)
|
||||
case ToolListDatabases:
|
||||
return r.listDatabases(ctx, args)
|
||||
case ToolListTables:
|
||||
return r.listTables(ctx, args)
|
||||
case ToolDescribeTable:
|
||||
return r.describeTable(ctx, args)
|
||||
case ToolGetSchemaContext:
|
||||
return r.getSchemaContext(ctx, args)
|
||||
case ToolExecuteQuery:
|
||||
return r.executeQuery(ctx, args)
|
||||
case ToolExecuteRedisCommand:
|
||||
return r.executeRedis(ctx, args)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown tool: %s", tool)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runtime) listConnections(ctx context.Context) (any, error) {
|
||||
_ = ctx
|
||||
list, err := r.loadConns()
|
||||
if err != nil {
|
||||
r.audit(AuditEntry{Tool: ToolListConnections, OK: false, Error: err.Error()})
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ConnectionSummary, 0, len(list))
|
||||
for _, c := range list {
|
||||
if err := r.Policy.AllowConnection(c.ID); err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, c.Summary())
|
||||
}
|
||||
r.audit(AuditEntry{Tool: ToolListConnections, OK: true, Summary: fmt.Sprintf("%d connections", len(out))})
|
||||
return map[string]any{"connections": out}, nil
|
||||
}
|
||||
|
||||
func argString(args map[string]any, key string) string {
|
||||
if args == nil {
|
||||
return ""
|
||||
}
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return ""
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(t)
|
||||
default:
|
||||
return strings.TrimSpace(fmt.Sprint(t))
|
||||
}
|
||||
}
|
||||
|
||||
func argInt(args map[string]any, key string, def int) int {
|
||||
if args == nil {
|
||||
return def
|
||||
}
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return def
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return int(t)
|
||||
case int:
|
||||
return t
|
||||
case int64:
|
||||
return int(t)
|
||||
case json.Number:
|
||||
n, _ := t.Int64()
|
||||
return int(n)
|
||||
case string:
|
||||
var n int
|
||||
fmt.Sscanf(t, "%d", &n)
|
||||
if n == 0 {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
default:
|
||||
return def
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runtime) listDatabases(ctx context.Context, args map[string]any) (any, error) {
|
||||
id := argString(args, "connection_id")
|
||||
c, err := r.resolveAllowed(id)
|
||||
if err != nil {
|
||||
r.audit(AuditEntry{Tool: ToolListDatabases, ConnectionID: id, OK: false, Error: err.Error()})
|
||||
return nil, err
|
||||
}
|
||||
req := c.ToConnReq()
|
||||
dbs, err := r.listDB(ctx, req)
|
||||
if err != nil {
|
||||
r.audit(AuditEntry{Tool: ToolListDatabases, ConnectionID: id, OK: false, Error: err.Error()})
|
||||
return nil, err
|
||||
}
|
||||
r.audit(AuditEntry{Tool: ToolListDatabases, ConnectionID: id, OK: true, Summary: fmt.Sprintf("%d databases", len(dbs))})
|
||||
return map[string]any{"databases": dbs}, nil
|
||||
}
|
||||
|
||||
func (r *Runtime) listTables(ctx context.Context, args map[string]any) (any, error) {
|
||||
id := argString(args, "connection_id")
|
||||
database := argString(args, "database")
|
||||
c, err := r.resolveAllowed(id)
|
||||
if err != nil {
|
||||
r.audit(AuditEntry{Tool: ToolListTables, ConnectionID: id, OK: false, Error: err.Error()})
|
||||
return nil, err
|
||||
}
|
||||
req := c.ToConnReq()
|
||||
if database != "" {
|
||||
req.Database = database
|
||||
}
|
||||
tables, err := r.listTbl(ctx, req)
|
||||
if err != nil {
|
||||
r.audit(AuditEntry{Tool: ToolListTables, ConnectionID: id, OK: false, Error: err.Error()})
|
||||
return nil, err
|
||||
}
|
||||
r.audit(AuditEntry{Tool: ToolListTables, ConnectionID: id, OK: true, Summary: fmt.Sprintf("%d tables", len(tables))})
|
||||
return map[string]any{"tables": tables, "database": req.Database}, nil
|
||||
}
|
||||
|
||||
func (r *Runtime) describeTable(ctx context.Context, args map[string]any) (any, error) {
|
||||
id := argString(args, "connection_id")
|
||||
database := argString(args, "database")
|
||||
table := argString(args, "table")
|
||||
if table == "" {
|
||||
err := fmt.Errorf("table required")
|
||||
r.audit(AuditEntry{Tool: ToolDescribeTable, ConnectionID: id, OK: false, Error: err.Error()})
|
||||
return nil, err
|
||||
}
|
||||
c, err := r.resolveAllowed(id)
|
||||
if err != nil {
|
||||
r.audit(AuditEntry{Tool: ToolDescribeTable, ConnectionID: id, OK: false, Error: err.Error()})
|
||||
return nil, err
|
||||
}
|
||||
req := c.ToConnReq()
|
||||
if database != "" {
|
||||
req.Database = database
|
||||
}
|
||||
req.Table = table
|
||||
cols, err := r.listCol(ctx, req)
|
||||
if err != nil {
|
||||
r.audit(AuditEntry{Tool: ToolDescribeTable, ConnectionID: id, OK: false, Error: err.Error()})
|
||||
return nil, err
|
||||
}
|
||||
r.audit(AuditEntry{Tool: ToolDescribeTable, ConnectionID: id, OK: true, Summary: table + fmt.Sprintf(" (%d cols)", len(cols))})
|
||||
return map[string]any{"table": table, "database": req.Database, "columns": cols}, nil
|
||||
}
|
||||
|
||||
func (r *Runtime) getSchemaContext(ctx context.Context, args map[string]any) (any, error) {
|
||||
id := argString(args, "connection_id")
|
||||
database := argString(args, "database")
|
||||
maxTables := argInt(args, "max_tables", 40)
|
||||
if maxTables <= 0 {
|
||||
maxTables = 40
|
||||
}
|
||||
if maxTables > 200 {
|
||||
maxTables = 200
|
||||
}
|
||||
c, err := r.resolveAllowed(id)
|
||||
if err != nil {
|
||||
r.audit(AuditEntry{Tool: ToolGetSchemaContext, ConnectionID: id, OK: false, Error: err.Error()})
|
||||
return nil, err
|
||||
}
|
||||
req := c.ToConnReq()
|
||||
if database != "" {
|
||||
req.Database = database
|
||||
}
|
||||
tables, err := r.listTbl(ctx, req)
|
||||
if err != nil {
|
||||
r.audit(AuditEntry{Tool: ToolGetSchemaContext, ConnectionID: id, OK: false, Error: err.Error()})
|
||||
return nil, err
|
||||
}
|
||||
var b strings.Builder
|
||||
n := 0
|
||||
for _, t := range tables {
|
||||
if n >= maxTables {
|
||||
break
|
||||
}
|
||||
colReq := req
|
||||
colReq.Table = t
|
||||
cols, err := r.listCol(ctx, colReq)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
parts := make([]string, 0, len(cols))
|
||||
for _, col := range cols {
|
||||
parts = append(parts, strings.TrimSpace(col.Name+" "+col.Type))
|
||||
}
|
||||
dbName := req.Database
|
||||
if dbName == "" {
|
||||
dbName = "default"
|
||||
}
|
||||
line := fmt.Sprintf("%s.%s(%s)\n", dbName, t, strings.Join(parts, ", "))
|
||||
if b.Len()+len(line) > schemaContextCap {
|
||||
break
|
||||
}
|
||||
b.WriteString(line)
|
||||
n++
|
||||
}
|
||||
text := b.String()
|
||||
if len(text) > schemaContextCap {
|
||||
text = text[:schemaContextCap]
|
||||
}
|
||||
r.audit(AuditEntry{Tool: ToolGetSchemaContext, ConnectionID: id, OK: true, Summary: fmt.Sprintf("%d tables, %d chars", n, len(text))})
|
||||
return map[string]any{"schema": text, "truncated": len(tables) > n || len(text) >= schemaContextCap}, nil
|
||||
}
|
||||
|
||||
func (r *Runtime) executeQuery(ctx context.Context, args map[string]any) (any, error) {
|
||||
id := argString(args, "connection_id")
|
||||
sqlText := argString(args, "sql")
|
||||
database := argString(args, "database")
|
||||
limit := r.Policy.ClampLimit(argInt(args, "limit", 0))
|
||||
|
||||
fail := func(msg string) (any, error) {
|
||||
r.audit(AuditEntry{Tool: ToolExecuteQuery, ConnectionID: id, OK: false, Error: msg, Summary: truncate(sqlText, 80)})
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
if sqlText == "" {
|
||||
return fail("sql required")
|
||||
}
|
||||
// P0 纯只读:写/DDL 一律拒绝;不做 write mode / confirm token。
|
||||
if ok, reason := IsReadOnlySQL(sqlText); !ok {
|
||||
return fail(reason)
|
||||
}
|
||||
|
||||
c, err := r.resolveAllowed(id)
|
||||
if err != nil {
|
||||
return fail(err.Error())
|
||||
}
|
||||
if strings.EqualFold(c.Driver, "redis") {
|
||||
return fail("redis connections do not accept SQL; use opsxdb_execute_redis_command")
|
||||
}
|
||||
|
||||
req := c.ToConnReq()
|
||||
if database != "" {
|
||||
req.Database = database
|
||||
}
|
||||
req.SQL = sqlText
|
||||
req.Limit = limit
|
||||
req.ConfirmDanger = false // 永不带确认放行
|
||||
|
||||
res := r.execSQL(ctx, req)
|
||||
if res.NeedConfirm {
|
||||
// 只读层已拦;若底层仍标 needConfirm,视为拒绝
|
||||
return fail("dangerous sql blocked (needConfirm): " + res.Error)
|
||||
}
|
||||
if res.Error != "" {
|
||||
return fail(res.Error)
|
||||
}
|
||||
r.audit(AuditEntry{
|
||||
Tool: ToolExecuteQuery, ConnectionID: id, OK: true,
|
||||
Summary: fmt.Sprintf("rows=%d truncated=%v sql=%s", res.RowCount, res.Truncated, truncate(sqlText, 60)),
|
||||
})
|
||||
return map[string]any{
|
||||
"columns": res.Columns,
|
||||
"rows": res.Rows,
|
||||
"rowCount": res.RowCount,
|
||||
"timeMs": res.TimeMs,
|
||||
"truncated": res.Truncated,
|
||||
"limit": limit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Runtime) executeRedis(ctx context.Context, args map[string]any) (any, error) {
|
||||
id := argString(args, "connection_id")
|
||||
command := argString(args, "command")
|
||||
database := argString(args, "database")
|
||||
|
||||
fail := func(msg string) (any, error) {
|
||||
r.audit(AuditEntry{Tool: ToolExecuteRedisCommand, ConnectionID: id, OK: false, Error: msg, Summary: truncate(command, 80)})
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
if command == "" {
|
||||
return fail("command required")
|
||||
}
|
||||
cmd, err := CheckRedisReadOnly(command)
|
||||
if err != nil {
|
||||
return fail(err.Error())
|
||||
}
|
||||
_ = cmd
|
||||
|
||||
c, err := r.resolveAllowed(id)
|
||||
if err != nil {
|
||||
return fail(err.Error())
|
||||
}
|
||||
if !strings.EqualFold(c.Driver, "redis") {
|
||||
return fail("connection is not redis")
|
||||
}
|
||||
|
||||
req := c.ToConnReq()
|
||||
if database != "" {
|
||||
req.Database = database
|
||||
}
|
||||
req.SQL = command
|
||||
req.ConfirmDanger = false
|
||||
|
||||
res := r.execSQL(ctx, req)
|
||||
if res.NeedConfirm {
|
||||
return fail("dangerous redis command blocked: " + res.Error)
|
||||
}
|
||||
if res.Error != "" {
|
||||
return fail(res.Error)
|
||||
}
|
||||
r.audit(AuditEntry{
|
||||
Tool: ToolExecuteRedisCommand, ConnectionID: id, OK: true,
|
||||
Summary: fmt.Sprintf("cmd=%s rows=%d", cmd, res.RowCount),
|
||||
})
|
||||
return map[string]any{
|
||||
"columns": res.Columns,
|
||||
"rows": res.Rows,
|
||||
"rowCount": res.RowCount,
|
||||
"timeMs": res.TimeMs,
|
||||
"truncated": res.Truncated,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
|
||||
// ResultJSON 将 Call 结果序列化为 MCP 文本内容。
|
||||
func ResultJSON(v any) string {
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package aitools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"opsxdb/server/internal/db"
|
||||
)
|
||||
|
||||
func TestIsReadOnlySQL(t *testing.T) {
|
||||
okCases := []string{
|
||||
"SELECT 1",
|
||||
"SHOW TABLES",
|
||||
"EXPLAIN SELECT * FROM t",
|
||||
"WITH c AS (SELECT 1 AS x) SELECT * FROM c",
|
||||
"PRAGMA table_info(t)",
|
||||
"DESCRIBE t",
|
||||
}
|
||||
for _, sql := range okCases {
|
||||
if ok, reason := IsReadOnlySQL(sql); !ok {
|
||||
t.Fatalf("expected read-only ok for %q: %s", sql, reason)
|
||||
}
|
||||
}
|
||||
bad := []string{
|
||||
"DELETE FROM t",
|
||||
"DELETE FROM t WHERE id=1",
|
||||
"DROP TABLE t",
|
||||
"INSERT INTO t VALUES (1)",
|
||||
"UPDATE t SET a=1",
|
||||
"CREATE TABLE t (id INT)",
|
||||
"ALTER TABLE t ADD c INT",
|
||||
"TRUNCATE TABLE t",
|
||||
"WITH c AS (SELECT 1) INSERT INTO t SELECT * FROM c",
|
||||
}
|
||||
for _, sql := range bad {
|
||||
if ok, _ := IsReadOnlySQL(sql); ok {
|
||||
t.Fatalf("expected reject for %q", sql)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRedisReadOnly(t *testing.T) {
|
||||
if _, err := CheckRedisReadOnly("GET foo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := CheckRedisReadOnly("TYPE foo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := CheckRedisReadOnly("SCAN 0 MATCH * COUNT 10"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, cmd := range []string{"FLUSHDB", "FLUSHALL", "KEYS *", "SET a 1", "DEL a"} {
|
||||
if _, err := CheckRedisReadOnly(cmd); err == nil {
|
||||
t.Fatalf("expected deny for %q", cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhitelistEmptyRejects(t *testing.T) {
|
||||
rt := NewRuntime(DefaultPolicy()) // empty whitelist
|
||||
rt.LoadConns = func() ([]SavedConnection, error) {
|
||||
return []SavedConnection{{ID: "c1", Name: "n", Driver: "sqlite"}}, nil
|
||||
}
|
||||
rt.FindConn = func(id string) (*SavedConnection, error) {
|
||||
return &SavedConnection{ID: id, Driver: "sqlite"}, nil
|
||||
}
|
||||
var audits []AuditEntry
|
||||
rt.Audit = func(e AuditEntry) { audits = append(audits, e) }
|
||||
|
||||
_, err := rt.Call(context.Background(), ToolExecuteQuery, map[string]any{
|
||||
"connection_id": "c1",
|
||||
"sql": "SELECT 1",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "whitelist empty") {
|
||||
t.Fatalf("expected whitelist empty error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDangerousSQLBlocked(t *testing.T) {
|
||||
rt := NewRuntime(Policy{
|
||||
AllowedConnectionIDs: []string{"c1"},
|
||||
Mode: ModeReadOnly,
|
||||
DefaultLimit: 100,
|
||||
MaxLimit: 500,
|
||||
})
|
||||
rt.FindConn = func(id string) (*SavedConnection, error) {
|
||||
return &SavedConnection{ID: id, Driver: "mysql", Host: "127.0.0.1", Port: 3306}, nil
|
||||
}
|
||||
called := false
|
||||
rt.ExecSQL = func(ctx context.Context, c db.ConnReq) db.ExecResult {
|
||||
called = true
|
||||
return db.ExecResult{}
|
||||
}
|
||||
_, err := rt.Call(context.Background(), ToolExecuteQuery, map[string]any{
|
||||
"connection_id": "c1",
|
||||
"sql": "DROP TABLE users",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected DROP blocked")
|
||||
}
|
||||
if called {
|
||||
t.Fatal("ExecSQL must not be called for dangerous/write SQL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSecretOx1(t *testing.T) {
|
||||
plain := "p@ss词"
|
||||
enc := "ox1." + base64.StdEncoding.EncodeToString([]byte(plain))
|
||||
if got := DecodeSecret(enc); got != plain {
|
||||
t.Fatalf("got %q want %q", got, plain)
|
||||
}
|
||||
if DecodeSecret("plain") != "plain" {
|
||||
t.Fatal("passthrough")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteListAndExec(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "t.db")
|
||||
if err := os.WriteFile(dbPath, []byte{}, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
init := db.ExecSQL(context.Background(), db.ConnReq{
|
||||
Driver: "sqlite", Database: dbPath,
|
||||
SQL: "CREATE TABLE person (id INTEGER PRIMARY KEY, name TEXT)",
|
||||
})
|
||||
if init.Error != "" {
|
||||
t.Fatal(init.Error)
|
||||
}
|
||||
_ = db.ExecSQL(context.Background(), db.ConnReq{
|
||||
Driver: "sqlite", Database: dbPath,
|
||||
SQL: "INSERT INTO person (name) VALUES ('a')", ConfirmDanger: true,
|
||||
})
|
||||
|
||||
rt := NewRuntime(Policy{
|
||||
AllowedConnectionIDs: []string{"sqlite1"},
|
||||
Mode: ModeReadOnly,
|
||||
DefaultLimit: 100,
|
||||
MaxLimit: 500,
|
||||
})
|
||||
rt.FindConn = func(id string) (*SavedConnection, error) {
|
||||
return &SavedConnection{ID: id, Name: "local", Driver: "sqlite", Database: dbPath}, nil
|
||||
}
|
||||
rt.LoadConns = func() ([]SavedConnection, error) {
|
||||
return []SavedConnection{{ID: "sqlite1", Name: "local", Driver: "sqlite", Database: dbPath}}, nil
|
||||
}
|
||||
|
||||
out, err := rt.Call(context.Background(), ToolListTables, map[string]any{"connection_id": "sqlite1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := out.(map[string]any)
|
||||
tables := m["tables"].([]string)
|
||||
found := false
|
||||
for _, tb := range tables {
|
||||
if tb == "person" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("tables=%v", tables)
|
||||
}
|
||||
|
||||
desc, err := rt.Call(context.Background(), ToolDescribeTable, map[string]any{
|
||||
"connection_id": "sqlite1", "table": "person",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = desc
|
||||
|
||||
q, err := rt.Call(context.Background(), ToolExecuteQuery, map[string]any{
|
||||
"connection_id": "sqlite1",
|
||||
"sql": "SELECT name FROM person",
|
||||
"limit": 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
qm := q.(map[string]any)
|
||||
if qm["rowCount"].(int) < 1 {
|
||||
t.Fatalf("rowCount=%v", qm["rowCount"])
|
||||
}
|
||||
|
||||
// write rejected
|
||||
if _, err := rt.Call(context.Background(), ToolExecuteQuery, map[string]any{
|
||||
"connection_id": "sqlite1",
|
||||
"sql": "INSERT INTO person (name) VALUES ('b')",
|
||||
}); err == nil {
|
||||
t.Fatal("insert must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListConnectionsFiltersWhitelist(t *testing.T) {
|
||||
rt := NewRuntime(Policy{AllowedConnectionIDs: []string{"a"}, Mode: ModeReadOnly, DefaultLimit: 100, MaxLimit: 500})
|
||||
rt.LoadConns = func() ([]SavedConnection, error) {
|
||||
return []SavedConnection{
|
||||
{ID: "a", Name: "A", Driver: "mysql", Host: "h", Port: 3306, Password: "secret"},
|
||||
{ID: "b", Name: "B", Driver: "mysql", Host: "h2", Port: 3306},
|
||||
}, nil
|
||||
}
|
||||
out, err := rt.Call(context.Background(), ToolListConnections, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conns := out.(map[string]any)["connections"].([]ConnectionSummary)
|
||||
if len(conns) != 1 || conns[0].ID != "a" {
|
||||
t.Fatalf("%+v", conns)
|
||||
}
|
||||
// ensure no password field leaked via JSON
|
||||
raw := ResultJSON(out)
|
||||
if strings.Contains(raw, "secret") || strings.Contains(raw, "password") {
|
||||
t.Fatalf("password leaked: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolNamesStable(t *testing.T) {
|
||||
names := ToolNames()
|
||||
if len(names) != 7 {
|
||||
t.Fatalf("want 7 tools, got %d", len(names))
|
||||
}
|
||||
if names[0] != ToolListConnections || names[len(names)-1] != ToolExecuteRedisCommand {
|
||||
t.Fatalf("%v", names)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package aitools
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const secretPrefix = "ox1."
|
||||
|
||||
// DecodeSecret 与前端 src/secrets.ts decodeSecret 对齐:ox1. + base64(utf8)。 // Logan.Li
|
||||
func DecodeSecret(stored string) string {
|
||||
if stored == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(stored, secretPrefix) {
|
||||
return stored
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(stored[len(secretPrefix):])
|
||||
if err != nil {
|
||||
return stored
|
||||
}
|
||||
// 前端 encodeURIComponent 再 btoa;解码侧 escape/atob/decodeURIComponent。
|
||||
// Go 侧:先按 latin1 字节还原,再尝试 URL unescape。
|
||||
s := string(raw)
|
||||
if u, err := url.QueryUnescape(s); err == nil {
|
||||
return u
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"opsxdb/server/internal/ai"
|
||||
"opsxdb/server/internal/aitools"
|
||||
"opsxdb/server/internal/db"
|
||||
"opsxdb/server/internal/docs"
|
||||
"opsxdb/server/internal/site"
|
||||
@@ -371,6 +372,28 @@ func NewRouter(token string) *gin.Engine {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "imported": n, "drivers": db.DriversStatus()})
|
||||
})
|
||||
|
||||
// POST /api/db/dump 表转储 SQL:format=structure|data|structure_data(对齐 Navicat「转储 SQL 文件」)
|
||||
api.POST("/db/dump", func(c *gin.Context) {
|
||||
req, err := bindConn(c, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Table) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少表名"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="dump-%s-%d.sql"`, sanitizeFileToken(req.Table), time.Now().Unix()))
|
||||
c.Header("Content-Type", "application/sql; charset=utf-8")
|
||||
if err := db.DumpTable(ctx, req, c.Writer); err != nil {
|
||||
if !c.Writer.Written() {
|
||||
c.Writer.Header().Del("Content-Disposition")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
api.POST("/ai/chat", func(c *gin.Context) {
|
||||
var req ai.ChatReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -389,11 +412,14 @@ func NewRouter(token string) *gin.Engine {
|
||||
api.GET("/ai/config", func(c *gin.Context) {
|
||||
cfg := ai.LoadConfig()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"provider": cfg.Provider,
|
||||
"baseURL": cfg.BaseURL,
|
||||
"model": cfg.Model,
|
||||
"hasKey": cfg.APIKey != "",
|
||||
"apiKey": cfg.MaskedKey(),
|
||||
"provider": cfg.Provider,
|
||||
"baseURL": cfg.BaseURL,
|
||||
"model": cfg.Model,
|
||||
"protocol": cfg.Protocol,
|
||||
"authStyle": cfg.AuthStyle,
|
||||
"allowInsecureHttp": cfg.AllowInsecureHTTP,
|
||||
"hasKey": cfg.APIKey != "",
|
||||
"apiKey": cfg.MaskedKey(),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -403,6 +429,12 @@ func NewRouter(token string) *gin.Engine {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 前端 key 留空表示「不改动」:保留已存 key,避免掩码回传语义下误清空
|
||||
if req.APIKey == "" {
|
||||
if old := ai.LoadConfig(); old.APIKey != "" {
|
||||
req.APIKey = old.APIKey
|
||||
}
|
||||
}
|
||||
if err := ai.SaveConfig(req); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -410,6 +442,38 @@ func NewRouter(token string) *gin.Engine {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
|
||||
|
||||
// 桌面连接落盘双写:供 MCP 进程读 ~/.opsxdb/connections.json(Logan.Li)
|
||||
api.GET("/db/connections-store", func(c *gin.Context) {
|
||||
list, err := aitools.LoadConnections()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []aitools.SavedConnection{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"connections": list})
|
||||
})
|
||||
|
||||
api.PUT("/db/connections-store", func(c *gin.Context) {
|
||||
var req struct {
|
||||
Connections []aitools.SavedConnection `json:"connections"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Connections == nil {
|
||||
req.Connections = []aitools.SavedConnection{}
|
||||
}
|
||||
if err := aitools.SaveConnections(req.Connections); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(req.Connections)})
|
||||
})
|
||||
|
||||
api.POST("/ai/generate", func(c *gin.Context) {
|
||||
var req ai.ChatReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -478,6 +542,24 @@ func exportContentType(format string) (contentType, ext string) {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// sanitizeFileToken 文件名安全化:库.表等标识符去掉路径分隔与控制字符。
|
||||
func sanitizeFileToken(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.TrimSpace(s) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
|
||||
r == '-', r == '_', r == '.', r == '@':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune('_')
|
||||
}
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return "table"
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func health(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "service": "opsxdb", "version": Version})
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user