bfi-cli 0.1.71 → 0.1.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/bfi-db-mcp.js +13 -0
- package/dist/mcp-db/main.d.mts +1 -0
- package/dist/mcp-db/main.mjs +287 -0
- package/package.json +7 -3
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import module from 'node:module';
|
|
4
|
+
|
|
5
|
+
if (module.enableCompileCache && !process.env.NODE_DISABLE_COMPILE_CACHE) {
|
|
6
|
+
try {
|
|
7
|
+
module.enableCompileCache();
|
|
8
|
+
} catch {
|
|
9
|
+
// Ignore errors
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
await import('../dist/mcp-db/main.mjs');
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import mysql from "mysql2/promise";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
const SEMICOLON_SPLIT = /;\s*(?=(?:[^']*'[^']*')*[^']*$)/;
|
|
9
|
+
const FIRST_WORD = /[a-zA-Z_]+/;
|
|
10
|
+
function no(reason) {
|
|
11
|
+
return {
|
|
12
|
+
ok: false,
|
|
13
|
+
kind: "query",
|
|
14
|
+
reason
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function stripComments(sql) {
|
|
18
|
+
let out = "";
|
|
19
|
+
let i = 0;
|
|
20
|
+
const len = sql.length;
|
|
21
|
+
while (i < len) {
|
|
22
|
+
const c = sql[i];
|
|
23
|
+
if (c === "'") {
|
|
24
|
+
out += c;
|
|
25
|
+
i++;
|
|
26
|
+
while (i < len) {
|
|
27
|
+
out += sql[i];
|
|
28
|
+
if (sql[i] === "'") {
|
|
29
|
+
if (i + 1 < len && sql[i + 1] === "'") {
|
|
30
|
+
out += "'";
|
|
31
|
+
i += 2;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
i++;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
i++;
|
|
38
|
+
}
|
|
39
|
+
} else if (c === "-" && sql[i + 1] === "-") while (i < len && sql[i] !== "\n") i++;
|
|
40
|
+
else if (c === "#") while (i < len && sql[i] !== "\n") i++;
|
|
41
|
+
else if (c === "/" && sql[i + 1] === "*") {
|
|
42
|
+
i += 2;
|
|
43
|
+
while (i + 1 < len && !(sql[i] === "*" && sql[i + 1] === "/")) i++;
|
|
44
|
+
i = Math.min(i + 2, len);
|
|
45
|
+
} else {
|
|
46
|
+
out += c;
|
|
47
|
+
i++;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
function firstKeyword(sql) {
|
|
53
|
+
const m = sql.match(FIRST_WORD);
|
|
54
|
+
return m ? m[0].toLowerCase() : "";
|
|
55
|
+
}
|
|
56
|
+
function checkGate(rawSql) {
|
|
57
|
+
if (!rawSql || !rawSql.trim()) return no("SQL 为空");
|
|
58
|
+
const stripped = stripComments(rawSql);
|
|
59
|
+
if (!stripped.trim()) return no("SQL 为空(仅注释)");
|
|
60
|
+
const stmts = stripped.trim().split(SEMICOLON_SPLIT).map((s) => s.trim()).filter(Boolean);
|
|
61
|
+
if (stmts.length > 1) return no(`仅支持单语句,实际 ${stmts.length} 条`);
|
|
62
|
+
const word = firstKeyword(stmts[0]);
|
|
63
|
+
switch (word) {
|
|
64
|
+
case "select":
|
|
65
|
+
case "with": return {
|
|
66
|
+
ok: true,
|
|
67
|
+
kind: "query"
|
|
68
|
+
};
|
|
69
|
+
case "explain": {
|
|
70
|
+
const rest = stmts[0].replace(/^explain\s+/i, "");
|
|
71
|
+
if (/^analyze/i.test(rest)) return no("EXPLAIN ANALYZE 会真实执行语句,拒绝");
|
|
72
|
+
const target = firstKeyword(rest);
|
|
73
|
+
if (target === "select" || target === "with") return {
|
|
74
|
+
ok: true,
|
|
75
|
+
kind: "explain"
|
|
76
|
+
};
|
|
77
|
+
return no("EXPLAIN 仅支持 SELECT/WITH 目标语句");
|
|
78
|
+
}
|
|
79
|
+
default: return no(`本地直连仅允许 SELECT/WITH/EXPLAIN,收到: ${(word || "空").toUpperCase()}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const DEFAULT_MAX_ROWS = 200;
|
|
83
|
+
const MAX_ROWS_LIMIT = 2e3;
|
|
84
|
+
const QUERY_TIMEOUT_MS = 3e4;
|
|
85
|
+
function errMessage(e) {
|
|
86
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
87
|
+
return msg.length > 300 ? `${msg.slice(0, 300)}...` : msg;
|
|
88
|
+
}
|
|
89
|
+
async function directExecute(dsn, sql, maxRows = DEFAULT_MAX_ROWS) {
|
|
90
|
+
const gate = checkGate(sql);
|
|
91
|
+
if (!gate.ok) return {
|
|
92
|
+
ok: false,
|
|
93
|
+
error_kind: "unsupported_sql",
|
|
94
|
+
message: gate.reason
|
|
95
|
+
};
|
|
96
|
+
const limit = Math.min(Math.max(maxRows, 1), MAX_ROWS_LIMIT);
|
|
97
|
+
let conn;
|
|
98
|
+
try {
|
|
99
|
+
conn = await mysql.createConnection({
|
|
100
|
+
uri: dsn,
|
|
101
|
+
connectTimeout: 8e3
|
|
102
|
+
});
|
|
103
|
+
} catch (e) {
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
error_kind: "connection_failed",
|
|
107
|
+
message: `连接失败: ${errMessage(e)}`
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
await conn.query("SELECT 1");
|
|
112
|
+
const started = Date.now();
|
|
113
|
+
const [rows, fields] = await conn.query({
|
|
114
|
+
sql,
|
|
115
|
+
timeout: QUERY_TIMEOUT_MS,
|
|
116
|
+
rowsAsArray: true
|
|
117
|
+
});
|
|
118
|
+
const arr = rows;
|
|
119
|
+
const truncated = arr.length > limit;
|
|
120
|
+
return {
|
|
121
|
+
ok: true,
|
|
122
|
+
mode: "direct(本地直连,词法级闸门)",
|
|
123
|
+
columns: fields?.map((f) => f.name) ?? [],
|
|
124
|
+
rows: arr.slice(0, limit),
|
|
125
|
+
row_count: Math.min(arr.length, limit),
|
|
126
|
+
truncated,
|
|
127
|
+
elapsed_ms: Date.now() - started
|
|
128
|
+
};
|
|
129
|
+
} catch (e) {
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
error_kind: "execute_failed",
|
|
133
|
+
message: errMessage(e)
|
|
134
|
+
};
|
|
135
|
+
} finally {
|
|
136
|
+
await conn.end().catch(() => {});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const GLOBAL_CONFIG_DIR = path.join(os.homedir(), ".bfi");
|
|
140
|
+
const GLOBAL_CONFIG_FILE = path.join(GLOBAL_CONFIG_DIR, "config.json");
|
|
141
|
+
path.join(".bfi", "lock.json");
|
|
142
|
+
function loadGlobalConfig() {
|
|
143
|
+
try {
|
|
144
|
+
if (fs.existsSync(GLOBAL_CONFIG_FILE)) {
|
|
145
|
+
const content = fs.readFileSync(GLOBAL_CONFIG_FILE, "utf8");
|
|
146
|
+
return JSON.parse(content);
|
|
147
|
+
}
|
|
148
|
+
} catch (error) {
|
|
149
|
+
console.warn(`Warning: Failed to load global config: ${error.message}`);
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
function resolveApiBase() {
|
|
154
|
+
return process.env.BFI_API_BASE || "http://172.20.35.178:8901";
|
|
155
|
+
}
|
|
156
|
+
function resolveToken() {
|
|
157
|
+
if (process.env.BFI_TOKEN) return process.env.BFI_TOKEN;
|
|
158
|
+
return loadGlobalConfig()?.token ?? null;
|
|
159
|
+
}
|
|
160
|
+
var RestError = class extends Error {
|
|
161
|
+
errorKind;
|
|
162
|
+
constructor(errorKind, message) {
|
|
163
|
+
super(message);
|
|
164
|
+
this.errorKind = errorKind;
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
function requireParam(name, value) {
|
|
168
|
+
if (!value || !value.trim()) throw new RestError("invalid_params", `${name} 不能为空:请从 db_list_envs 返回结果中原样复制(如 env_code=core1_3)`);
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
async function post(path, payload, fetchImpl = fetch) {
|
|
172
|
+
const token = resolveToken();
|
|
173
|
+
if (!token) throw new RestError("unauthorized", "未找到 bfi token:请先执行 `bfi auth login --token <sk-token>`(token 获取地址 https://bfi.yyuap.com/user/profile),或设置 BFI_TOKEN 环境变量");
|
|
174
|
+
let resp;
|
|
175
|
+
try {
|
|
176
|
+
resp = await fetchImpl(resolveApiBase() + path, {
|
|
177
|
+
method: "POST",
|
|
178
|
+
headers: {
|
|
179
|
+
"Content-Type": "application/json",
|
|
180
|
+
Authorization: `Bearer ${token}`
|
|
181
|
+
},
|
|
182
|
+
body: JSON.stringify(payload)
|
|
183
|
+
});
|
|
184
|
+
} catch (e) {
|
|
185
|
+
throw new RestError("upstream_unreachable", `bfi-api(${resolveApiBase()})不可达:${e instanceof Error ? e.message : String(e)}`);
|
|
186
|
+
}
|
|
187
|
+
if (resp.status === 401) throw new RestError("unauthorized", "v2 token 无效或过期:请重新 `bfi auth login --token <token>`");
|
|
188
|
+
const body = await resp.json().catch(() => ({}));
|
|
189
|
+
if (body.code === 0) return body.data;
|
|
190
|
+
throw new RestError(body.data?.error_kind || "upstream_error", body.msg || "bfi-api 返回错误");
|
|
191
|
+
}
|
|
192
|
+
async function listEnvs(keyword, envType, fetchImpl) {
|
|
193
|
+
return post("/bficli/api/v2/db/envs", {
|
|
194
|
+
keyword,
|
|
195
|
+
env_type: envType,
|
|
196
|
+
enabled_only: true
|
|
197
|
+
}, fetchImpl);
|
|
198
|
+
}
|
|
199
|
+
async function execSql(envCode, sql, maxRows, fetchImpl) {
|
|
200
|
+
return post("/bficli/api/v2/db/exec", {
|
|
201
|
+
env_code: requireParam("env_code", envCode),
|
|
202
|
+
sql: requireParam("sql", sql),
|
|
203
|
+
max_rows: maxRows
|
|
204
|
+
}, fetchImpl);
|
|
205
|
+
}
|
|
206
|
+
async function explainSql(envCode, sql, fetchImpl) {
|
|
207
|
+
return post("/bficli/api/v2/db/explain", {
|
|
208
|
+
env_code: requireParam("env_code", envCode),
|
|
209
|
+
sql: requireParam("sql", sql)
|
|
210
|
+
}, fetchImpl);
|
|
211
|
+
}
|
|
212
|
+
function textResult(payload, isError = false) {
|
|
213
|
+
return {
|
|
214
|
+
content: [{
|
|
215
|
+
type: "text",
|
|
216
|
+
text: JSON.stringify(payload, null, 2)
|
|
217
|
+
}],
|
|
218
|
+
isError
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
function wrap(fn) {
|
|
222
|
+
return async (args) => {
|
|
223
|
+
try {
|
|
224
|
+
return textResult(await fn(args));
|
|
225
|
+
} catch (e) {
|
|
226
|
+
return textResult({
|
|
227
|
+
ok: false,
|
|
228
|
+
error_kind: e instanceof RestError ? e.errorKind : "internal_error",
|
|
229
|
+
message: e instanceof Error ? e.message : String(e)
|
|
230
|
+
}, true);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
function buildServer() {
|
|
235
|
+
const server = new McpServer({
|
|
236
|
+
name: "bfi-db",
|
|
237
|
+
version: "0.1.0"
|
|
238
|
+
}, { instructions: [
|
|
239
|
+
"数据库取证通道(只读):查询环境列表 → EXPLAIN 执行计划(优先)→ 必要时小行数 SELECT 采样。",
|
|
240
|
+
"DDL/DML 默认被环境权限位拒绝;db_direct 为用户自备连接的本地直连,词法级闸门仅放行 SELECT/WITH/EXPLAIN,结果无审计,写报告须标注\"本地直连取证\"。",
|
|
241
|
+
"执行前先用 db_list_envs 锁定 env_code(对应诊断的 appCode 先行铁律)。",
|
|
242
|
+
"降级链:环境清单匹配不到目标环境(env_not_found),或选中环境连接不可达(connection_failed)时,向用户说明并**提示附上数据库连接串**(mysql://user:pwd@host:3306/db),改走 db_direct 本地直连兜底。"
|
|
243
|
+
].join("\n") });
|
|
244
|
+
server.registerTool("db_list_envs", {
|
|
245
|
+
description: "获取可查询的数据库环境列表(env_code/env_name/db_type/datacenter/权限位 allow_ddl·dml·query·explain)。不含密码。执行任何 SQL 前必须先用本工具锁定 env_code。",
|
|
246
|
+
inputSchema: {
|
|
247
|
+
keyword: z.string().optional().describe("模糊关键字,匹配 env_code/env_name/datacenter(如 core3、商开)"),
|
|
248
|
+
env_type: z.enum([
|
|
249
|
+
"private",
|
|
250
|
+
"dev",
|
|
251
|
+
"prod"
|
|
252
|
+
]).optional().describe("环境类型过滤")
|
|
253
|
+
}
|
|
254
|
+
}, wrap((args) => listEnvs(args.keyword, args.env_type)));
|
|
255
|
+
server.registerTool("db_execute", {
|
|
256
|
+
description: "在指定环境执行单条 SQL(只读取证)。仅 SELECT/WITH/SHOW/DESC/EXPLAIN;DML/DDL 需环境显式授权位否则 permission_denied。行数上限默认 200。非 mysql 库自动翻译(mysql 语法输入即可)。诊断场景优先 db_explain。",
|
|
257
|
+
inputSchema: {
|
|
258
|
+
env_code: z.string().min(1, "env_code 不能为空:请从 db_list_envs 返回的 env_code 字段原样复制(如 core1_3)").describe("环境编码,来自 db_list_envs 返回的 env_code 字段(如 core1_3)原样传入"),
|
|
259
|
+
sql: z.string().min(1, "sql 不能为空").describe("单条 SQL(mysql 语法)"),
|
|
260
|
+
max_rows: z.number().int().min(1).max(2e3).optional().describe("行数上限,默认 200")
|
|
261
|
+
}
|
|
262
|
+
}, wrap((args) => execSql(args.env_code, args.sql, args.max_rows)));
|
|
263
|
+
server.registerTool("db_explain", {
|
|
264
|
+
description: "获取执行计划(慢 SQL 实证首选)。目标语句必须为 SELECT/WITH;gscm 按 db_type 组装 EXPLAIN(oracle 两步 EXPLAIN PLAN + DBMS_XPLAN)。结果为计划行,EXPLAIN 结果进报告须附原始计划行。",
|
|
265
|
+
inputSchema: {
|
|
266
|
+
env_code: z.string().min(1, "env_code 不能为空:请从 db_list_envs 返回的 env_code 字段原样复制(如 core1_3)").describe("环境编码,来自 db_list_envs 返回的 env_code 字段(如 core1_3)原样传入"),
|
|
267
|
+
sql: z.string().min(1, "sql 不能为空").describe("目标 SELECT/WITH 语句(mysql 语法)")
|
|
268
|
+
}
|
|
269
|
+
}, wrap((args) => explainSql(args.env_code, args.sql)));
|
|
270
|
+
server.registerTool("db_direct", {
|
|
271
|
+
description: "用户自备连接串的本地直连(不经服务端,无审计)。词法级闸门仅放行单条 SELECT/WITH/EXPLAIN(EXPLAIN ANALYZE 拒绝),执行前连接预检。仅当用户明确提供连接串且环境通道不可达时使用;报告引用本工具结果必须标注\"本地直连取证\"。",
|
|
272
|
+
inputSchema: {
|
|
273
|
+
dsn: z.string().min(1, "dsn 不能为空").describe("连接串 mysql://user:pwd@host:3306/db(密码仅内存持有)"),
|
|
274
|
+
sql: z.string().min(1, "sql 不能为空").describe("单条 SELECT/WITH/EXPLAIN"),
|
|
275
|
+
max_rows: z.number().int().min(1).max(2e3).optional().describe("行数上限,默认 200")
|
|
276
|
+
}
|
|
277
|
+
}, wrap((args) => directExecute(args.dsn, args.sql, args.max_rows)));
|
|
278
|
+
return server;
|
|
279
|
+
}
|
|
280
|
+
async function main() {
|
|
281
|
+
await buildServer().connect(new StdioServerTransport());
|
|
282
|
+
}
|
|
283
|
+
main().catch((e) => {
|
|
284
|
+
console.error("[bfi-db-mcp] 启动失败:", e);
|
|
285
|
+
process.exit(1);
|
|
286
|
+
});
|
|
287
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bfi-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.73",
|
|
4
4
|
"description": "用友大业财BFI统一命令行工具,支持技能,技能包,元数据,UI元数据等",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"bfi": "./bin/bfi.js"
|
|
7
|
+
"bfi": "./bin/bfi.js",
|
|
8
|
+
"bfi-db-mcp": "./bin/bfi-db-mcp.js"
|
|
8
9
|
},
|
|
9
10
|
"files": [
|
|
10
11
|
"dist",
|
|
@@ -46,7 +47,10 @@
|
|
|
46
47
|
"node": ">=18"
|
|
47
48
|
},
|
|
48
49
|
"dependencies": {
|
|
50
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
49
51
|
"@vercel/detect-agent": "^1.2.3",
|
|
50
|
-
"
|
|
52
|
+
"mysql2": "^3.14.0",
|
|
53
|
+
"yaml": "^2.8.3",
|
|
54
|
+
"zod": "^3.25.0"
|
|
51
55
|
}
|
|
52
56
|
}
|