@szc-ft/mcp-szcd-client 0.28.2 → 0.30.0

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.
@@ -24,6 +24,7 @@ export class BrowserEngine {
24
24
  this._activeFrame = null; // 当前活跃 iframe 上下文(微前端场景)
25
25
  this._adapter = null; // 懒加载 AntAdapter
26
26
  this._lastActWasDropdown = false; // 用于 act 入口自动 closeAllDropdowns
27
+ this._wujieContext = null; // wujie 微前端模式 { type, frameCount, wujieApp? }
27
28
  }
28
29
 
29
30
  /**
@@ -72,6 +73,138 @@ export class BrowserEngine {
72
73
  }
73
74
  }
74
75
 
76
+ /**
77
+ * 检测 wujie 微前端模式(WebComponent / iframe)
78
+ * @returns {{ type: 'webcomponent'|'iframe'|'none', frameCount: number, wujieApp?: object }}
79
+ */
80
+ async _detectWujieMode() {
81
+ const frames = this.page.frames();
82
+ const frameCount = frames.length;
83
+ const mainFrame = this.page.mainFrame();
84
+ // JSON 输出返回完整文本不截断;stderr 日志仅截取前 200 字符预览
85
+ const LOG_PREVIEW_LEN = 200;
86
+
87
+ // 检查主文档中是否有 <wujie-app> 自定义元素(WebComponent 模式标志)
88
+ const wujieInfo = await mainFrame.evaluate(() => {
89
+ const app = document.querySelector('wujie-app');
90
+ if (!app) return null;
91
+ const sr = app.shadowRoot;
92
+ const body = sr?.querySelector('body');
93
+ const fullText = body?.innerText || '';
94
+ return {
95
+ tagName: app.tagName,
96
+ className: app.className,
97
+ hasShadowRoot: !!sr,
98
+ shadowChildren: sr?.children?.length || 0,
99
+ shadowBodyText: fullText,
100
+ shadowBodyTextLength: fullText.length,
101
+ };
102
+ }).catch(() => null);
103
+
104
+ if (wujieInfo) {
105
+ const ctx = { type: 'webcomponent', frameCount, wujieApp: wujieInfo };
106
+ process.stderr.write(`[wujie] WebComponent 模式: <${wujieInfo.tagName} class="${wujieInfo.className}"> shadowRoot=${wujieInfo.hasShadowRoot} children=${wujieInfo.shadowChildren} frames=${frameCount}\n`);
107
+ // stderr 只输出预览(不刷屏),JSON 中返回全文
108
+ const preview = wujieInfo.shadowBodyText?.substring(0, LOG_PREVIEW_LEN) || '';
109
+ process.stderr.write(`[wujie] shadow body (${wujieInfo.shadowBodyTextLength} chars): "${preview}${wujieInfo.shadowBodyTextLength > LOG_PREVIEW_LEN ? '...' : ''}"\n`);
110
+ // 列出所有 frame 供排障
111
+ for (let i = 0; i < frames.length; i++) {
112
+ process.stderr.write(`[wujie] frame[${i}]: ${frames[i].url().substring(0, 100)}\n`);
113
+ }
114
+ return ctx;
115
+ }
116
+
117
+ if (frameCount > 1) {
118
+ process.stderr.write(`[wujie] iframe 模式: ${frameCount} frames\n`);
119
+ for (let i = 0; i < frames.length; i++) {
120
+ process.stderr.write(`[wujie] frame[${i}]: ${frames[i].url().substring(0, 100)}\n`);
121
+ }
122
+ return { type: 'iframe', frameCount };
123
+ }
124
+
125
+ process.stderr.write(`[wujie] 未检测到微前端 (frames=${frameCount})\n`);
126
+ return { type: 'none', frameCount };
127
+ }
128
+
129
+ /**
130
+ * 安全获取页面列表(优先同步 targets() 避免 pages() 挂死 + 泄露 WebSocket)
131
+ */
132
+ async _safeGetPages() {
133
+ // 1. 优先使用同步 targets(),不会挂死也不会泄露未完成的 promise
134
+ try {
135
+ const targets = this.browser.targets();
136
+ const pageTargets = targets.filter((t) => t.type() === "page");
137
+ if (pageTargets.length > 0) {
138
+ const pages = [];
139
+ for (const t of pageTargets) {
140
+ try {
141
+ const p = await Promise.race([
142
+ t.page(),
143
+ new Promise((_, reject) => setTimeout(() => reject(new Error("page() timeout")), 3000)),
144
+ ]);
145
+ if (p) pages.push(p);
146
+ } catch {
147
+ // 跳过无法获取的 target
148
+ }
149
+ }
150
+ if (pages.length > 0) return pages;
151
+ }
152
+ } catch {
153
+ // targets() 失败
154
+ }
155
+
156
+ // 2. 回退:CDP Target.getTargets
157
+ try {
158
+ const client = await this.browser.target().createCDPSession();
159
+ const { targetInfos } = await client.send("Target.getTargets");
160
+ const pageInfos = targetInfos.filter((t) => t.type === "page");
161
+ const pages = [];
162
+ for (const info of pageInfos) {
163
+ try {
164
+ const target = await Promise.race([
165
+ this.browser.waitForTarget((t) => t.url() === info.url, { timeout: 2000 }),
166
+ new Promise((_, reject) => setTimeout(() => reject(new Error("waitForTarget timeout")), 3000)),
167
+ ]);
168
+ if (target) {
169
+ const p = await Promise.race([
170
+ target.page(),
171
+ new Promise((_, reject) => setTimeout(() => reject(new Error("page() timeout")), 3000)),
172
+ ]);
173
+ if (p) pages.push(p);
174
+ }
175
+ } catch {
176
+ // skip
177
+ }
178
+ }
179
+ if (pages.length > 0) return pages;
180
+ } catch {
181
+ // CDP 回退也失败
182
+ }
183
+
184
+ // 3. 最后手段:只能尝试 pages()(带严格超时,用完立即 disconnect/reconnect 清理)
185
+ try {
186
+ const pages = await Promise.race([
187
+ this.browser.pages(),
188
+ new Promise((_, reject) => setTimeout(() => reject(new Error("pages() timeout")), 5000)),
189
+ ]);
190
+ if (pages && pages.length > 0) return pages;
191
+ } catch {
192
+ // pages() 超时;注意此时 browser.pages() 的 WebSocket 可能泄露
193
+ // 清理:断开后重新连接
194
+ try {
195
+ const wsUrl = this.browser.wsEndpoint();
196
+ const puppeteer = await loadPuppeteer();
197
+ await this.browser.disconnect();
198
+ this.browser = await puppeteer.connect({ browserWSEndpoint: wsUrl, defaultViewport: null });
199
+ } catch {
200
+ // reconnect 失败,继续用当前 browser
201
+ }
202
+ }
203
+
204
+ // 4. 最后手段:newPage()
205
+ return [await this.browser.newPage()];
206
+ }
207
+
75
208
  /**
76
209
  * 模式 A:连接到用户已有的 Chrome
77
210
  */
@@ -82,7 +215,7 @@ export class BrowserEngine {
82
215
  this.ownedBrowser = false;
83
216
 
84
217
  // 获取页面:优先使用指定 URL 匹配的标签页
85
- const pages = await this.browser.pages();
218
+ const pages = await this._safeGetPages();
86
219
  if (options.pageUrl) {
87
220
  this.page = pages.find((p) => p.url().includes(options.pageUrl))
88
221
  || await this.browser.newPage();
@@ -96,6 +229,9 @@ export class BrowserEngine {
96
229
  await this.page.setViewport(options.viewport);
97
230
  }
98
231
 
232
+ // 微前端模式检测
233
+ this._wujieContext = await this._detectWujieMode();
234
+
99
235
  return {
100
236
  mode: "connect",
101
237
  cdpUrl,
@@ -223,6 +359,7 @@ export class BrowserEngine {
223
359
  frameUrl: targetFrame.frame.url(),
224
360
  matchedBy: targetFrame.matchedBy,
225
361
  },
362
+ wujieContext: this._wujieContext,
226
363
  frames,
227
364
  tree,
228
365
  interactiveElements,
@@ -247,7 +384,10 @@ export class BrowserEngine {
247
384
 
248
385
  const targetFrame = await this._resolveObserveFrame(options);
249
386
  if (options.menuPath) {
250
- return this._actMenuPath(targetFrame, options);
387
+ // 菜单在 main frame 外壳中渲染,不随子应用切换
388
+ const menuFrame = this.page.mainFrame();
389
+ const menuTarget = { frame: menuFrame, frameId: "main", matchedBy: "main (menu shell)" };
390
+ return this._actMenuPath(menuTarget, options);
251
391
  }
252
392
 
253
393
  const index = options.index !== undefined ? Number.parseInt(options.index, 10) : undefined;
@@ -308,17 +448,36 @@ export class BrowserEngine {
308
448
 
309
449
  function findBySelector(selector) {
310
450
  if (!selector) return null;
451
+ // text== 精确匹配(Playwright 兼容):只匹配 innerText 完全相等的元素
452
+ if (selector.startsWith("text==")) {
453
+ const expected = selector.slice(6).trim();
454
+ const filter = (el) => isVisibleEnabled(el);
455
+ const interactive = interactiveElements().filter(filter);
456
+ let hit = interactive.find((el) => getText(el) === expected || getName(el, getText(el)) === expected);
457
+ if (hit) return hit;
458
+ const all = Array.from(document.querySelectorAll("*")).filter(filter);
459
+ return all.find((el) => getText(el) === expected) || null;
460
+ }
461
+ // text= 模糊匹配:优先选 innerText 最短的元素(离目标文字最近的叶子节点)
462
+ // 避免命中包含目标文字的大容器(如 drawer 整体 div)
311
463
  if (selector.startsWith("text=")) {
312
464
  const expected = selector.slice(5).trim();
313
- // 默认仅匹配 visible+enabled 元素,避免命中 ant-select 的隐藏 input、收起菜单里的 li
314
- // 需要包含隐藏元素时显式 includeHidden=true
315
465
  const includeHidden = actOptions.includeHidden === true;
316
466
  const filter = (el) => includeHidden || isVisibleEnabled(el);
317
467
  const interactive = interactiveElements().filter(filter);
318
- let hit = interactive.find((el) => getText(el).includes(expected) || getName(el, getText(el)).includes(expected));
319
- if (hit) return hit;
468
+ let hits = interactive.filter((el) => getText(el).includes(expected) || getName(el, getText(el)).includes(expected));
469
+ if (hits.length > 0) {
470
+ // 按 innerText 长度升序:最短的最可能是目标元素本身而非父容器
471
+ hits.sort((a, b) => getText(a).length - getText(b).length);
472
+ return hits[0];
473
+ }
320
474
  const all = Array.from(document.querySelectorAll("*")).filter(filter);
321
- return all.find((el) => getText(el).includes(expected)) || null;
475
+ hits = all.filter((el) => getText(el).includes(expected));
476
+ if (hits.length > 0) {
477
+ hits.sort((a, b) => getText(a).length - getText(b).length);
478
+ return hits[0];
479
+ }
480
+ return null;
322
481
  }
323
482
  if (selector.startsWith("role=")) {
324
483
  const match = selector.match(/^role=([^\[]+)(?:\[name="([^"]+)"\])?/);
@@ -944,6 +1103,15 @@ export class BrowserEngine {
944
1103
  }
945
1104
 
946
1105
  async _resolveObserveFrame(options = {}) {
1106
+ // testCase setup 已预定位 iframe,且调用方未显式传 frame 参数 → 直接复用
1107
+ if (this._activeFrame && options.frameIndex === undefined && !options.frameUrlContains && !options.frameContentContains) {
1108
+ const frames = this.page.frames();
1109
+ const idx = frames.indexOf(this._activeFrame);
1110
+ if (idx >= 0) {
1111
+ return { frame: this._activeFrame, frameId: idx === 0 ? "main" : `frame-${idx}`, matchedBy: "activeFrame (from testCase setup)" };
1112
+ }
1113
+ }
1114
+
947
1115
  const frames = this.page.frames();
948
1116
  const mainFrame = this.page.mainFrame();
949
1117
 
@@ -973,6 +1141,14 @@ export class BrowserEngine {
973
1141
  }
974
1142
  }
975
1143
 
1144
+ // 无显式 frame 参数时:WebComponent 模式默认用 sandbox iframe(含子应用实际 DOM)
1145
+ if (this._wujieContext?.type === 'webcomponent' && frames.length > 1) {
1146
+ const sandboxIdx = frames.findIndex((f) => f !== mainFrame && !f.url().startsWith('about:'));
1147
+ if (sandboxIdx >= 0) {
1148
+ return { frame: frames[sandboxIdx], frameId: `frame-${sandboxIdx}`, matchedBy: 'webcomponent sandbox iframe' };
1149
+ }
1150
+ }
1151
+
976
1152
  const mainIndex = frames.indexOf(mainFrame);
977
1153
  return { frame: mainFrame, frameId: mainIndex <= 0 ? "main" : `frame-${mainIndex}`, matchedBy: "main" };
978
1154
  }
@@ -1097,8 +1273,23 @@ export class BrowserEngine {
1097
1273
 
1098
1274
  const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
1099
1275
  const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
1100
- return Array.from(document.querySelectorAll("a,button,input,select,textarea,summary,[role],[tabindex],[data-testid],[class*='btn'],[class*='button'],[class*='menu-item'],[class*='ant-btn'],[class*='ant-menu'],[class*='ant-select'],[class*='ant-input']"))
1101
- .filter(isCandidate)
1276
+
1277
+ // 1. 先收集 portal 弹窗/抽屉中的元素(排在前面,避免 text= 匹配到外层容器)
1278
+ const portalSelectors = '.ant-drawer:not(.ant-drawer-closed), .ant-drawer-open .ant-drawer-content-wrapper, .ant-modal-wrap:not([style*="display: none"]), .ant-dropdown:not(.ant-dropdown-hidden)';
1279
+ const portalEls = [];
1280
+ document.querySelectorAll(portalSelectors).forEach(portal => {
1281
+ portal.querySelectorAll('a,button,input,select,textarea,summary,[role],[tabindex],[data-testid],[class*="btn"],[class*="button"],[class*="ant-btn"]').forEach(el => {
1282
+ if (isCandidate(el)) portalEls.push(el);
1283
+ });
1284
+ });
1285
+
1286
+ // 2. 再收集 body 中的元素,但要排除已在 portal 中的(避免重复)
1287
+ const portalSet = new Set(portalEls);
1288
+ const bodyEls = Array.from(document.querySelectorAll("a,button,input,select,textarea,summary,[role],[tabindex],[data-testid],[class*='btn'],[class*='button'],[class*='menu-item'],[class*='ant-btn'],[class*='ant-menu'],[class*='ant-select'],[class*='ant-input']"))
1289
+ .filter(el => isCandidate(el) && !portalSet.has(el));
1290
+
1291
+ // portal 元素排前面(优先级更高),body 元素排后面
1292
+ return portalEls.concat(bodyEls)
1102
1293
  .slice(0, limit)
1103
1294
  .map((el, idx) => {
1104
1295
  const rect = el.getBoundingClientRect();
@@ -1693,7 +1884,7 @@ export class BrowserEngine {
1693
1884
  * 查找已打开的标签页(connect 模式,端口可变场景)
1694
1885
  */
1695
1886
  async _findPage(step) {
1696
- const pages = await this.browser.pages();
1887
+ const pages = await this._safeGetPages();
1697
1888
  let matched = null;
1698
1889
  let matchReason = "";
1699
1890
 
@@ -2114,7 +2305,15 @@ export class BrowserEngine {
2114
2305
  const results = [];
2115
2306
  const dataset = testCase.dataset || [{ __row: 0 }];
2116
2307
 
2117
- // setup 阶段:frame 预定位
2308
+ // setup 阶段:page 切换 + frame 预定位
2309
+ if (testCase.setup?.findPage) {
2310
+ const pageResult = await this._findPage(testCase.setup.findPage);
2311
+ if (!pageResult.found) {
2312
+ throw new Error(`findPage failed: ${pageResult.error} - available pages: ${JSON.stringify(pageResult.pages?.map(p => p.url))}`);
2313
+ }
2314
+ // page 切换后需要重新检测 wujie 模式
2315
+ this._wujieContext = await this._detectWujieMode();
2316
+ }
2118
2317
  if (testCase.setup?.findFrame && !this._activeFrame) {
2119
2318
  const resolved = await this._resolveObserveFrame(testCase.setup.findFrame);
2120
2319
  this._activeFrame = resolved.frame;
@@ -8,11 +8,12 @@
8
8
  * node local-browser-executor.js --action act --menu-path "目录管理/数据集目录" --observe-after
9
9
  * node local-browser-executor.js --action api-capture --wait 15000 --include-response-body
10
10
  * node local-browser-executor.js --action assert-api --capture-file /tmp/browser-api-capture.json --max-failed 0
11
+ * node local-browser-executor.js --action evaluate --expression "(() => ({a:1}))()" --frame-index 1
11
12
  * node local-browser-executor.js --plan '<JSON>' --output /tmp/result.json
12
13
  * node local-browser-executor.js --plan-file /path/to/plan.json --output /tmp/result.json
13
14
  *
14
15
  * 参数:
15
- * --action 直接执行动作,当前支持 observe/act/assert/api-capture
16
+ * --action 直接执行动作,当前支持 observe/act/assert/evaluate/api-capture/assert-api/run-task/test-case/plan
16
17
  * --plan 测试计划 JSON 字符串
17
18
  * --plan-file 测试计划 JSON 文件路径(与 --plan 二选一)
18
19
  * --output 结果输出文件路径(默认 stdout)
@@ -228,6 +229,15 @@ function parseArgs() {
228
229
  case "--test-report":
229
230
  parsed.testReport = args[++i];
230
231
  break;
232
+ case "--expression":
233
+ parsed.expression = args[++i];
234
+ break;
235
+ case "--expect-result-contains":
236
+ parsed.expectResultContains = args[++i];
237
+ break;
238
+ case "--fail-on-falsy":
239
+ parsed.failOnFalsy = true;
240
+ break;
231
241
  case "--help":
232
242
  case "-h":
233
243
  printHelp();
@@ -249,11 +259,12 @@ function printHelp() {
249
259
  node local-browser-executor.js --action api-capture --wait 15000 --include-response-body [options]
250
260
  node local-browser-executor.js --action assert-api --capture-file /tmp/browser-api-capture.json --max-failed 0 [options]
251
261
  node local-browser-executor.js --action run-task --task menu-api-scan --capture-input-file /tmp/menu-tree.json [options]
262
+ node local-browser-executor.js --action evaluate --expression "(() => ({a:1}))()" [options]
252
263
  node local-browser-executor.js --plan '<JSON>' [options]
253
264
  node local-browser-executor.js --plan-file <path> [options]
254
265
 
255
266
  选项:
256
- --action <name> 直接执行动作,当前支持 observe/act/assert/api-capture/assert-api/run-task
267
+ --action <name> 直接执行动作,当前支持 observe/act/assert/evaluate/api-capture/assert-api/run-task/test-case/plan
257
268
  --plan <json> 测试计划 JSON 字符串
258
269
  --plan-file <path> 测试计划 JSON 文件路径
259
270
  --output <path> 结果输出文件路径(默认 stdout)
@@ -274,6 +285,9 @@ function printHelp() {
274
285
  --menu-path <a/b> act 时按菜单路径展开并点击,如 "目录管理/数据集目录"
275
286
  --wait-after-each <ms> menu-path 每步点击后的等待时间(默认 300)
276
287
  --observe-after act 后再次 observe,返回 before/act/after 闭环结果
288
+ --expression <js> evaluate 时执行的 JS 表达式(需用 IIFE 包裹)
289
+ --expect-result-contains <text> evaluate 时期望结果包含的文本
290
+ --fail-on-falsy evaluate 时结果为 falsy 则标记失败
277
291
  --url-contains-assert <text> assert 当前 URL 包含文本
278
292
  --text-contains <text> assert 页面文本包含内容
279
293
  --expect-visible assert selector 可见
@@ -493,6 +507,34 @@ async function executeAction(args) {
493
507
  });
494
508
  return { type: "actObserve", act: actResult, after, browserInfo: initResult, startTime: startedAt, endTime: new Date().toISOString() };
495
509
  }
510
+ case "evaluate": {
511
+ // 先通过 _resolveObserveFrame 设置 _activeFrame,使 safeEval 在正确的 frame 中执行
512
+ const resolved = await engine._resolveObserveFrame({
513
+ frameUrlContains: args.frameUrlContains,
514
+ frameContentContains: args.frameContentContains,
515
+ frameIndex: args.frameIndex,
516
+ });
517
+ engine._activeFrame = resolved.frame;
518
+ const evalResult = await engine.safeEval(args.expression);
519
+ const evalPassed = args.failOnFalsy ? !!evalResult : true;
520
+ const containsMatch = args.expectResultContains
521
+ ? JSON.stringify(evalResult).includes(args.expectResultContains)
522
+ : true;
523
+ return {
524
+ type: "evaluate",
525
+ expression: args.expression,
526
+ result: evalResult,
527
+ passed: evalPassed && containsMatch,
528
+ frameTarget: resolved.frameId,
529
+ checks: {
530
+ failOnFalsy: args.failOnFalsy ? { expected: "truthy", actual: !!evalResult, passed: !!evalResult } : null,
531
+ expectResultContains: args.expectResultContains ? { expected: args.expectResultContains, passed: containsMatch } : null,
532
+ },
533
+ browserInfo: initResult,
534
+ startTime: startedAt,
535
+ endTime: new Date().toISOString(),
536
+ };
537
+ }
496
538
  case "assert": {
497
539
  const expect = {};
498
540
  if (args.expectVisible) expect.visible = true;
@@ -509,6 +551,11 @@ async function executeAction(args) {
509
551
  });
510
552
  return { type: "assert", ...result, browserInfo: initResult, startTime: startedAt, endTime: new Date().toISOString() };
511
553
  }
554
+ case "plan": {
555
+ const plan = loadPlan(args);
556
+ const result = await executePlan(plan, { cdpUrl: args.cdpUrl, pageUrl: args.pageUrl || args.urlContains });
557
+ return { type: "plan", ...result, startTime: startedAt, endTime: new Date().toISOString() };
558
+ }
512
559
  case "api-capture":
513
560
  case "apiCapture": {
514
561
  const result = await engine.captureApi({
@@ -628,7 +675,7 @@ async function executeAction(args) {
628
675
  return {
629
676
  error: `未知 action: ${args.action}`,
630
677
  errorCode: "UNKNOWN_ACTION",
631
- supportedActions: ["observe", "act", "assert", "api-capture", "assert-api", "run-task", "test-case"],
678
+ supportedActions: ["observe", "act", "assert", "evaluate", "api-capture", "assert-api", "run-task", "test-case", "plan"],
632
679
  startTime: startedAt,
633
680
  endTime: new Date().toISOString(),
634
681
  };
@@ -0,0 +1 @@
1
+ {"type": "module"}
@@ -0,0 +1,122 @@
1
+ ---
2
+ name: szcd-upload-zip
3
+ description: |
4
+ 本地 ZIP 文件上传与解压工具。将用户本地的 zip 压缩包通过 `upload-zip` REST 端点上传到 szcd-mcp-server,
5
+ 自动解压到服务器的 `uploads/<zip包名>/` 目录,并返回解压后的文件列表。
6
+ 上传后解压的文件可通过文件下载接口按路径获取或供其他流程使用。
7
+ compatibility:
8
+ tools:
9
+ - run_command
10
+ runtime:
11
+ - node >= 18(读取配置用,仅 `cat ~/.szcd-mcp-config.json` 场景)
12
+ - curl(文件上传所需)
13
+ ---
14
+
15
+ # ZIP 上传与解压工具(szcd-upload-zip)
16
+
17
+ ## 概述
18
+
19
+ 当 AI 需要将用户本地的 ZIP 压缩包上传到 szcd-mcp-server 时,使用此技能。
20
+ 上传后服务器自动解压,开发者或 AI 可通过下载 API 访问解压后的文件。
21
+
22
+ ## 适用场景
23
+
24
+ - 用户有一个本地 zip 包需要上传到服务器存储
25
+ - zipped 源码/资源包需要上传到服务器供后续流程使用
26
+ - 需要在服务器端解压 zip 并取用其中的文件
27
+
28
+ ## 工作流
29
+
30
+ ### 第 0 步:探测运行时
31
+
32
+ ```bash
33
+ node --version 2>/dev/null && echo "NODE_OK"; \
34
+ curl --version 2>/dev/null | head -1 && echo "CURL_OK"
35
+ ```
36
+
37
+ ### 第 1 步:读取配置获取服务器地址和 API Key
38
+
39
+ ```bash
40
+ cat ~/.szcd-mcp-config.json
41
+ ```
42
+
43
+ 配置示例:
44
+ ```json
45
+ {
46
+ "MCP_SERVER_URL": "http://localhost:3456",
47
+ "MCP_TIMEOUT": 30000,
48
+ "MCP_API_KEY": "your-api-key"
49
+ }
50
+ ```
51
+
52
+ - 若 `MCP_API_KEY` 为空字符串,则上传时不带 `X-API-Key` header
53
+ - 若 `MCP_SERVER_URL` 未配置,默认使用 `http://localhost:3456`
54
+
55
+ ### 第 2 步:上传 ZIP 文件
56
+
57
+ ```bash
58
+ # 从 ~/.szcd-mcp-config.json 取 URL 和 API Key
59
+ MCP_URL=$(node -e "const c=require('fs').readFileSync(require('os').homedir()+'/.szcd-mcp-config.json','utf8');const j=JSON.parse(c);console.log(j.MCP_SERVER_URL||'http://localhost:3456')")
60
+ MCP_KEY=$(node -e "const c=require('fs').readFileSync(require('os').homedir()+'/.szcd-mcp-config.json','utf8');const j=JSON.parse(c);console.log(j.MCP_API_KEY||'')")
61
+
62
+ # 上传 zip 文件
63
+ if [ -n "$MCP_KEY" ]; then
64
+ curl -s -X POST "$MCP_URL/upload-zip" -H "X-API-Key: $MCP_KEY" -F "file=@<zip文件路径>"
65
+ else
66
+ curl -s -X POST "$MCP_URL/upload-zip" -F "file=@<zip文件路径>"
67
+ fi
68
+ ```
69
+
70
+ 其中 `<zip文件路径>` 替换为实际 zip 文件的绝对路径。
71
+
72
+ **响应示例:**
73
+ ```json
74
+ {
75
+ "ok": true,
76
+ "dir_name": "my-package",
77
+ "extract_dir": "/scity/zengzhijie/mcp/uploads/my-package",
78
+ "file_count": 5,
79
+ "files": [
80
+ { "path": "index.html", "size": 2048 },
81
+ { "path": "js/app.js", "size": 15360 },
82
+ { "path": "css/style.css", "size": 4096 },
83
+ { "path": "images/logo.png", "size": 819200 },
84
+ { "path": "data/config.json", "size": 512 }
85
+ ]
86
+ }
87
+ ```
88
+
89
+ ### 第 3 步:查看解压后的文件
90
+
91
+ 上传成功后,可通过下载 API 查看解压后的文件或取单个文件内容:
92
+
93
+ ```bash
94
+ # 列出解压目录
95
+ curl -s "$MCP_URL/api/download/list?dir=/scity/zengzhijie/mcp/uploads/my-package" \
96
+ ${MCP_KEY:+-H "X-API-Key: $MCP_KEY"}
97
+
98
+ # 下载单个文件
99
+ curl -s "$MCP_URL/api/download?path=/scity/zengzhijie/mcp/uploads/my-package/data/config.json" \
100
+ ${MCP_KEY:+-H "X-API-Key: $MCP_KEY"}
101
+ ```
102
+
103
+ ## 限制说明
104
+
105
+ - 仅支持真正的 ZIP 格式(magic number `PK\x03\x04` 校验),改扩展名无用
106
+ - 单文件大小上限 100MB
107
+ - 同名 zip 包名会覆盖服务器上已有的目录
108
+ - 解压后文件持久存储,无自动过期
109
+ - 上传路径始终为 `/upload-zip`(不含 `/api/` 前缀)
110
+
111
+ ## 完整命令示例(一步完成)
112
+
113
+ ```bash
114
+ # 读取配置
115
+ MCP_URL=$(node -e "try{const c=require('fs').readFileSync(require('os').homedir()+'/.szcd-mcp-config.json','utf8');const j=JSON.parse(c);console.log(j.MCP_SERVER_URL||'http://localhost:3456')}catch{console.log('http://localhost:3456')}")
116
+ MCP_KEY=$(node -e "try{const c=require('fs').readFileSync(require('os').homedir()+'/.szcd-mcp-config.json','utf8');const j=JSON.parse(c);console.log(j.MCP_API_KEY||'')}catch{console.log('')}")
117
+
118
+ # 上传
119
+ curl -s -X POST "$MCP_URL/upload-zip" \
120
+ ${MCP_KEY:+-H "X-API-Key: $MCP_KEY"} \
121
+ -F "file=@/path/to/archive.zip" | python3 -m json.tool
122
+ ```