bfi-cli 0.1.105 → 0.1.106

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.
@@ -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-pm/main.mjs');
@@ -0,0 +1 @@
1
+ export {}
@@ -0,0 +1,282 @@
1
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { CallToolRequestSchema, CallToolResultSchema, ListToolsRequestSchema, ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js";
4
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
6
+ import { readFile, realpath, stat } from "node:fs/promises";
7
+ import path from "node:path";
8
+ const PM_ORIGIN = "https://bfi.yyuap.com";
9
+ var PmError = class extends Error {
10
+ errorKind;
11
+ constructor(errorKind, message) {
12
+ super(message);
13
+ this.errorKind = errorKind;
14
+ }
15
+ };
16
+ function resolveCredentials() {
17
+ const ak = (process.env.BFI_PM_AK || "").trim();
18
+ const sk = (process.env.BFI_PM_SK || "").trim();
19
+ if (!ak || !sk || ak.includes("${") || sk.includes("${")) return null;
20
+ return {
21
+ ak,
22
+ sk,
23
+ env: (process.env.BFI_PM_ENV || "prod").trim(),
24
+ productLine: (process.env.BFI_PM_PRODUCT_LINE || "YonBIP").trim()
25
+ };
26
+ }
27
+ function credentialHint() {
28
+ return "未找到 PM 凭证:请在插件配置中填写 PM Access Key / Secret Key(`/plugin configure bfi-perf-stability-kit@bfi-skills`),或设置环境变量 BFI_PM_AK / BFI_PM_SK";
29
+ }
30
+ var PmRemote = class {
31
+ url;
32
+ credentials;
33
+ fetchImpl;
34
+ client = null;
35
+ constructor(url = process.env.BFI_PM_URL || "https://bfi.yyuap.com/bfi-api/bficli/api/v1/mcp/mcp", credentials = resolveCredentials(), fetchImpl) {
36
+ this.url = url;
37
+ this.credentials = credentials;
38
+ this.fetchImpl = fetchImpl;
39
+ }
40
+ hasCredentials() {
41
+ return this.credentials !== null;
42
+ }
43
+ async listTools() {
44
+ try {
45
+ return (await (await this.ensure()).request({ method: "tools/list" }, ListToolsResultSchema)).tools;
46
+ } catch (e) {
47
+ console.error(`[bfi-pm-mcp] 远端工具列表获取失败: ${messageOf(e)}`);
48
+ this.client = null;
49
+ return [];
50
+ }
51
+ }
52
+ async callTool(name, args) {
53
+ const client = await this.ensure();
54
+ try {
55
+ return await client.request({
56
+ method: "tools/call",
57
+ params: {
58
+ name,
59
+ arguments: args ?? {}
60
+ }
61
+ }, CallToolResultSchema);
62
+ } catch (e) {
63
+ return this.handleRemoteFailure(e);
64
+ }
65
+ }
66
+ async ensure() {
67
+ if (this.client) return this.client;
68
+ if (!this.credentials) throw new PmError("unauthorized", credentialHint());
69
+ const client = new Client({
70
+ name: "bfi-pm-mcp",
71
+ version: "0.1.0"
72
+ });
73
+ const transport = new StreamableHTTPClientTransport(new URL(this.url), {
74
+ requestInit: { headers: {
75
+ "X-PM-AK": this.credentials.ak,
76
+ "X-PM-SK": this.credentials.sk,
77
+ "X-PM-ENV": this.credentials.env,
78
+ "X-PM-PRODUCT-LINE": this.credentials.productLine
79
+ } },
80
+ ...this.fetchImpl ? { fetch: this.fetchImpl } : {}
81
+ });
82
+ try {
83
+ await client.connect(transport);
84
+ } catch (e) {
85
+ throw new PmError("pm_unreachable", `bfi-pm 服务不可达(${this.url}): ${messageOf(e)}。请检查网络或 BFI_PM_URL`);
86
+ }
87
+ this.client = client;
88
+ return client;
89
+ }
90
+ handleRemoteFailure(e) {
91
+ this.client = null;
92
+ const message = messageOf(e);
93
+ if (/401|unauthorized|Unauthorized/i.test(message)) throw new PmError("unauthorized", `PM 凭证无效或过期。${credentialHint()}`);
94
+ throw new PmError("pm_error", `bfi-pm 远端调用失败: ${message}`);
95
+ }
96
+ };
97
+ function messageOf(e) {
98
+ return e instanceof Error ? e.message : String(e);
99
+ }
100
+ const DEFAULT_MAX_MB = 100;
101
+ function resolveUploadRoots() {
102
+ return [...(process.env.BFI_UPLOAD_ROOTS || "").split(path.delimiter).map((s) => s.trim()).filter(Boolean), process.cwd()].map((p) => path.resolve(p));
103
+ }
104
+ function resolveMaxBytes() {
105
+ const mb = Number(process.env.BFI_UPLOAD_MAX_MB || DEFAULT_MAX_MB);
106
+ return (Number.isFinite(mb) && mb > 0 ? mb : DEFAULT_MAX_MB) * 1024 * 1024;
107
+ }
108
+ async function resolveLocalFile(filePath, roots = resolveUploadRoots(), maxBytes = resolveMaxBytes()) {
109
+ if (!filePath || !filePath.trim()) throw new Error("file_path 不能为空");
110
+ const requested = path.resolve(filePath);
111
+ let real;
112
+ try {
113
+ real = await realpath(requested);
114
+ } catch {
115
+ throw new Error(`文件不存在或不可读: ${filePath}`);
116
+ }
117
+ if (!roots.some((root) => real === root || real.startsWith(root + path.sep))) throw new Error(`路径不在允许目录内: ${real}(允许:${roots.join(", ")};可用 BFI_UPLOAD_ROOTS 扩展)`);
118
+ const info = await stat(real);
119
+ if (!info.isFile()) throw new Error(`不是普通文件: ${real}`);
120
+ if (info.size > maxBytes) throw new Error(`文件过大: ${(info.size / 1024 / 1024).toFixed(1)}MB(上限 ${Math.round(maxBytes / 1024 / 1024)}MB)`);
121
+ return {
122
+ realPath: real,
123
+ name: path.basename(real),
124
+ size: info.size
125
+ };
126
+ }
127
+ function absoluteUploadUrl(uploadUrl, origin = PM_ORIGIN) {
128
+ return new URL(uploadUrl, origin).toString();
129
+ }
130
+ async function uploadToTicket(url, file, fetchImpl = fetch) {
131
+ const data = await readFile(file.realPath);
132
+ const form = new FormData();
133
+ form.append("file", new Blob([new Uint8Array(data)]), file.name);
134
+ const resp = await fetchImpl(url, {
135
+ method: "POST",
136
+ body: form
137
+ });
138
+ const text = await resp.text();
139
+ if (!resp.ok) throw new Error(`附件上传失败 HTTP ${resp.status}: ${text.slice(0, 200)}`);
140
+ try {
141
+ return JSON.parse(text);
142
+ } catch {
143
+ throw new Error(`附件上传响应非 JSON: ${text.slice(0, 200)}`);
144
+ }
145
+ }
146
+ const LOCAL_TOOLS = [{
147
+ name: "attachment_upload",
148
+ description: "把本地文件上传到 PM 附件库(零失真:文件内容不经模型)。file_path 为本地路径(须在允许目录内),file_name 可覆盖显示名。返回 {aid, fileName, size};aid 可直接用于 add_comment 的 attache_ids 或 create_defect 的 attachments。仅上传用户明确指定的文件,禁止自行猜测或遍历路径。",
149
+ inputSchema: {
150
+ type: "object",
151
+ properties: {
152
+ file_path: {
153
+ type: "string",
154
+ description: "本地文件路径(绝对或相对 cwd)"
155
+ },
156
+ file_name: {
157
+ type: "string",
158
+ description: "上传显示文件名(默认取文件名)"
159
+ }
160
+ },
161
+ required: ["file_path"]
162
+ }
163
+ }, {
164
+ name: "add_comment_with_attachment",
165
+ description: "一次完成“逐个上传本地文件 → 添加评论”:content 为评论正文,file_paths 为本地文件列表。返回远端 add_comment 结果(含 attache_ids)。适合报告/日志等大文件,无需 Bash 与 base64。",
166
+ inputSchema: {
167
+ type: "object",
168
+ properties: {
169
+ object_id: {
170
+ type: "string",
171
+ description: "缺陷编号(如 BIP-BUG-xxx)或 aid"
172
+ },
173
+ content: {
174
+ type: "string",
175
+ description: "评论正文"
176
+ },
177
+ file_paths: {
178
+ type: "array",
179
+ items: { type: "string" },
180
+ description: "本地文件路径列表(按顺序上传)"
181
+ }
182
+ },
183
+ required: ["object_id", "file_paths"]
184
+ }
185
+ }];
186
+ function textResult(payload, isError = false) {
187
+ return {
188
+ content: [{
189
+ type: "text",
190
+ text: JSON.stringify(payload, null, 2)
191
+ }],
192
+ isError
193
+ };
194
+ }
195
+ function extractJson(result) {
196
+ if (result.structuredContent && typeof result.structuredContent === "object") return result.structuredContent;
197
+ const blocks = Array.isArray(result.content) ? result.content : [];
198
+ for (const block of blocks) if (block && typeof block === "object" && block.type === "text") {
199
+ const text = String(block.text ?? "");
200
+ try {
201
+ return JSON.parse(text);
202
+ } catch {
203
+ return { text };
204
+ }
205
+ }
206
+ return {};
207
+ }
208
+ function buildServer(remote, fetchImpl) {
209
+ const pm = remote ?? new PmRemote();
210
+ const server = new Server({
211
+ name: "bfi-pm",
212
+ version: "0.1.0"
213
+ }, {
214
+ capabilities: { tools: {} },
215
+ instructions: [
216
+ "BFI 研发管理平台(PM)通道:缺陷/特性/子任务/评论/附件/元数据。",
217
+ "查询与建单优先用远端工具(search_defects / query_defect / create_defect / add_comment 等)。",
218
+ "附件上传用本地工具 attachment_upload(file_path):文件不经模型,支持大文件,无需 Bash。",
219
+ "需要“上传并评论”一步完成时用 add_comment_with_attachment。",
220
+ pm.hasCredentials() ? "" : `当前未配置 PM 凭证:${credentialHint()}`
221
+ ].filter(Boolean).join("\n")
222
+ });
223
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
224
+ const remoteTools = await pm.listTools();
225
+ const localNames = new Set(LOCAL_TOOLS.map((t) => t.name));
226
+ return { tools: [...LOCAL_TOOLS, ...remoteTools.filter((t) => !localNames.has(t.name))] };
227
+ });
228
+ async function uploadOne(filePath, fileName) {
229
+ if (!pm.hasCredentials()) throw new PmError("unauthorized", credentialHint());
230
+ const file = await resolveLocalFile(filePath);
231
+ const ticket = extractJson(await pm.callTool("get_upload_ticket", { file_name: (fileName || file.name).trim() }));
232
+ const uploadUrl = String(ticket.upload_url || "");
233
+ if (!uploadUrl) throw new PmError("ticket_failed", `签发 ticket 失败: ${JSON.stringify(ticket).slice(0, 200)}`);
234
+ const uploaded = await uploadToTicket(absoluteUploadUrl(uploadUrl), file, fetchImpl);
235
+ if (!uploaded.aid) throw new PmError("upload_failed", `上传响应缺少 aid: ${JSON.stringify(uploaded).slice(0, 200)}`);
236
+ return {
237
+ ...uploaded,
238
+ fileName: uploaded.fileName || file.name,
239
+ size: file.size
240
+ };
241
+ }
242
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
243
+ const { name, arguments: args } = request.params;
244
+ const input = args ?? {};
245
+ try {
246
+ if (name === "attachment_upload") return textResult(await uploadOne(String(input.file_path ?? ""), input.file_name ? String(input.file_name) : void 0));
247
+ if (name === "add_comment_with_attachment") {
248
+ const paths = Array.isArray(input.file_paths) ? input.file_paths.map(String) : [];
249
+ if (!input.object_id) throw new PmError("invalid_params", "object_id 不能为空");
250
+ if (!paths.length) throw new PmError("invalid_params", "file_paths 不能为空");
251
+ const uploads = [];
252
+ for (const p of paths) uploads.push(await uploadOne(p));
253
+ const aids = uploads.map((u) => String(u.aid));
254
+ return textResult({
255
+ ok: true,
256
+ uploads,
257
+ comment: extractJson(await pm.callTool("add_comment", {
258
+ object_id: String(input.object_id),
259
+ content: input.content ? String(input.content) : "",
260
+ attache_ids: aids
261
+ }))
262
+ });
263
+ }
264
+ return await pm.callTool(name, input);
265
+ } catch (e) {
266
+ return textResult({
267
+ ok: false,
268
+ error_kind: e instanceof PmError ? e.errorKind : "internal_error",
269
+ message: e instanceof Error ? e.message : String(e)
270
+ }, true);
271
+ }
272
+ });
273
+ return server;
274
+ }
275
+ async function main() {
276
+ await buildServer().connect(new StdioServerTransport());
277
+ }
278
+ main().catch((e) => {
279
+ console.error("[bfi-pm-mcp] 启动失败:", e);
280
+ process.exit(1);
281
+ });
282
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bfi-cli",
3
- "version": "0.1.105",
3
+ "version": "0.1.106",
4
4
  "description": "用友大业财BFI统一命令行工具,支持技能,技能包,元数据,UI元数据等",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,8 @@
8
8
  "bfi-db-mcp": "bin/bfi-db-mcp.js",
9
9
  "bfi-codesearch-mcp": "bin/bfi-codesearch-mcp.js",
10
10
  "bfi-weknora-mcp": "bin/bfi-weknora-mcp.js",
11
- "bfi-knowledge-search-mcp": "bin/bfi-knowledge-search-mcp.js"
11
+ "bfi-knowledge-search-mcp": "bin/bfi-knowledge-search-mcp.js",
12
+ "bfi-pm-mcp": "bin/bfi-pm-mcp.js"
12
13
  },
13
14
  "files": [
14
15
  "dist",