zwcad-mcp 0.2.0 → 0.2.1

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/README.md CHANGED
@@ -78,12 +78,27 @@ npx -y zwcad-mcp --prepare # 只准备运行时后退出
78
78
 
79
79
  | 现象 | 处理 |
80
80
  |---|---|
81
- | 首次启动等待 30~60 秒 | **正常**,正在安装依赖,之后约 2 秒启动 |
81
+ | 首次使用提示「正在首次初始化」 | **正常**,正在后台安装依赖(约 40 秒),握手本身只需约 120 ms |
82
82
  | 报「未找到可用的 Python」 | 装 Python 3.10+,或设 `ZWCAD_MCP_PYTHON` 指向已有解释器 |
83
83
  | 装依赖失败(网络) | 设 `ZWCAD_MCP_PIP_INDEX=https://pypi.tuna.tsinghua.edu.cn/simple` |
84
84
  | 运行时损坏 | 删 `%USERPROFILE%\.workbuddy\zwcad-mcp-runtime\` 后重连,自动重建 |
85
85
  | 机械工具报 `TYPELIB_NOT_LOADED` | 需中望机械CAD 已启动;平台工具不受影响 |
86
86
 
87
+ ### 错误日志
88
+
89
+ 启动器会把**失败事件**写入本地文件,便于排查与反馈:
90
+
91
+ ```
92
+ %USERPROFILE%\.workbuddy\zwcad-mcp-runtime\logs\error.log
93
+ ```
94
+
95
+ - 每行一条 JSON:时间、事件名、Node 版本、诊断细节
96
+ - **只记失败**,不记成功的工具调用,不含图纸内容
97
+ - 单文件上限 1 MB,自动轮转为 `.1`~`.3`,总量 ≤ 4 MB
98
+ - `--doctor` 末尾会直接打印最近 10 条摘要,多数情况无需打开文件
99
+
100
+ 可用 `ZWCAD_MCP_LOG_DIR` 改变存放位置。
101
+
87
102
  ### 关于 `-2147312566`(加载类型库/DLL 时出错)
88
103
 
89
104
  这是机械类型库的已知情况,**不是文件缺失或权限问题**。实测根因:`ZwmToolKit.tlb` 内部引用了另一个仅在 ZWCAD 进程运行时才可解析的类型库。
@@ -98,6 +113,7 @@ npx -y zwcad-mcp --prepare # 只准备运行时后退出
98
113
  |---|---|
99
114
  | `ZWCAD_MCP_PYTHON` | 指定 Python 解释器路径,跳过自动探测(无效时报错而非静默回退) |
100
115
  | `ZWCAD_MCP_PIP_INDEX` | 指定 pip 镜像源 |
116
+ | `ZWCAD_MCP_LOG_DIR` | 指定错误日志目录,默认在 `zwcad-mcp-runtime/logs` |
101
117
 
102
118
  ## 仓库结构
103
119
 
package/bridge.js CHANGED
@@ -79,9 +79,13 @@ function log(...parts) {
79
79
  process.stderr.write('[zwcad-mcp] ' + parts.join(' ') + '\n');
80
80
  }
81
81
 
82
+ /** 错误日志(落盘),失败不影响主流程 */
83
+ const { logError, logFilePath } = require('./lib/errorlog');
84
+
82
85
  function fatal(err) {
83
86
  const msg = err && err.stack ? err.stack : String(err);
84
87
  log('启动失败:' + msg);
88
+ logError('launcher.fatal', { error: err });
85
89
  process.exit(1);
86
90
  }
87
91
 
@@ -260,6 +264,12 @@ function resolvePython() {
260
264
  log('使用 Python ' + ver.join('.') + ':' + exe);
261
265
  return { exe, version: ver };
262
266
  }
267
+ logError('runtime.python.not_found', {
268
+ candidates: pythonCandidates(),
269
+ tried,
270
+ minVersion: MIN_PYTHON.join('.'),
271
+ envOverride: process.env.ZWCAD_MCP_PYTHON || null,
272
+ });
263
273
  throw new Error(
264
274
  '未找到可用的 Python(需要 >= ' +
265
275
  MIN_PYTHON.join('.') +
@@ -384,7 +394,20 @@ async function installDeps(venvPython) {
384
394
  args.push(...requiredPackages());
385
395
 
386
396
  log('首次运行,安装依赖(约 30~60 秒,请稍候)…');
387
- await runStreaming(venvPython, args, '安装依赖');
397
+ const startedAt = Date.now();
398
+ try {
399
+ await runStreaming(venvPython, args, '安装依赖');
400
+ } catch (e) {
401
+ logError('runtime.install.failed', {
402
+ error: e,
403
+ packageCount: requiredPackages().length,
404
+ packages: requiredPackages(),
405
+ pipIndex: index || null,
406
+ elapsedMs: Date.now() - startedAt,
407
+ venvPython,
408
+ });
409
+ throw e;
410
+ }
388
411
  }
389
412
 
390
413
  /**
@@ -490,6 +513,31 @@ function doctor() {
490
513
  } else {
491
514
  log('--- 虚拟环境 --- 尚未创建(首次启动时会自动创建)');
492
515
  }
516
+
517
+ log('--- 错误日志 ---');
518
+ const lp = logFilePath();
519
+ if (fs.existsSync(lp)) {
520
+ const st = fs.statSync(lp);
521
+ log(' 路径:' + lp);
522
+ log(' 大小:' + (st.size / 1024).toFixed(1) + ' KB,最近写入 ' + st.mtime.toISOString());
523
+ log(' 查看最近 10 条:');
524
+ try {
525
+ const lines = fs.readFileSync(lp, 'utf8').trim().split('\n').slice(-10);
526
+ for (const line of lines) {
527
+ try {
528
+ const r = JSON.parse(line);
529
+ log(' [' + r.time + '] ' + r.event);
530
+ } catch {
531
+ log(' ' + line.slice(0, 120));
532
+ }
533
+ }
534
+ } catch {
535
+ log(' (读取失败)');
536
+ }
537
+ } else {
538
+ log(' 暂无错误记录(这是好事)');
539
+ log(' 路径:' + lp + '(首次出错时自动创建)');
540
+ }
493
541
  }
494
542
 
495
543
  /* ------------------------------------------------------------------ *
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 错误日志:把启动器与 Python 侧的失败事件写到本地文件,便于事后排查。
4
+ *
5
+ * 设计取舍
6
+ * --------
7
+ * 1. **只记失败**,不记成功。成功事件写日志只会淹没真正有用的信息,
8
+ * 而且调用量上来后文件会迅速膨胀。
9
+ * 2. **只记本地,不联网**。图纸内容、文件路径都可能涉密,
10
+ * 用户遇到问题时可以把文件发给我们,由人工判断是否适合上报。
11
+ * 3. **写入绝不抛异常**。日志本身出问题不能影响 MCP 服务运行——
12
+ * 这是日志系统最常见的自伤方式。
13
+ * 4. **自动轮转**。单文件上限 1 MB,超过后转成 `.1`,最多保留 3 个历史文件,
14
+ * 总量控制在 4 MB 以内,不会占满用户磁盘。
15
+ *
16
+ * 存放位置
17
+ * --------
18
+ * %USERPROFILE%\.workbuddy\zwcad-mcp-runtime\logs\
19
+ *
20
+ * 放在 runtime 目录下,用户执行「删掉 runtime 目录重置」时会一并清理,
21
+ * 不会留下孤儿文件。
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ const fs = require('fs');
27
+ const os = require('os');
28
+ const path = require('path');
29
+
30
+ /** 单文件上限:1 MB */
31
+ const MAX_BYTES = 1024 * 1024;
32
+ /** 保留的历史文件数 */
33
+ const MAX_BACKUPS = 3;
34
+
35
+ /**
36
+ * 解析日志目录。
37
+ *
38
+ * 允许用环境变量覆盖,便于排查时把日志指到更方便的位置。
39
+ */
40
+ function resolveLogDir() {
41
+ if (process.env.ZWCAD_MCP_LOG_DIR) {
42
+ return process.env.ZWCAD_MCP_LOG_DIR;
43
+ }
44
+ return path.join(os.homedir(), '.workbuddy', 'zwcad-mcp-runtime', 'logs');
45
+ }
46
+
47
+ /**
48
+ * 轮转:当前文件超过上限时,依次改名 .1 / .2 / .3,最老的被丢弃。
49
+ *
50
+ * 用 rename 而不是复制,避免大文件复制带来的 IO 抖动。
51
+ */
52
+ function rotateIfNeeded(file) {
53
+ let size;
54
+ try {
55
+ size = fs.statSync(file).size;
56
+ } catch {
57
+ return; // 文件不存在,无需轮转
58
+ }
59
+ if (size < MAX_BYTES) return;
60
+
61
+ // 从最老的开始往后挪,腾出 .1 的位置
62
+ for (let i = MAX_BACKUPS; i >= 1; i--) {
63
+ const src = i === 1 ? file : file + '.' + (i - 1);
64
+ const dst = file + '.' + i;
65
+ try {
66
+ if (fs.existsSync(src)) fs.renameSync(src, dst);
67
+ } catch {
68
+ /* 轮转失败不影响主流程 */
69
+ }
70
+ }
71
+ }
72
+
73
+ /** 把值安全地转成可 JSON 序列化的形式 */
74
+ function sanitize(value, depth = 0) {
75
+ if (value === null || value === undefined) return value;
76
+ if (depth > 4) return '[深度超限]';
77
+
78
+ const t = typeof value;
79
+ if (t === 'string') {
80
+ // 抑制超长字符串(例如整段 Python 堆栈)
81
+ return value.length > 4000 ? value.slice(0, 4000) + '…[截断]' : value;
82
+ }
83
+ if (t === 'number' || t === 'boolean') return value;
84
+ if (t === 'function') return '[Function]';
85
+
86
+ if (value instanceof Error) {
87
+ return {
88
+ name: value.name,
89
+ message: value.message,
90
+ stack: value.stack ? value.stack.slice(0, 4000) : undefined,
91
+ };
92
+ }
93
+
94
+ if (Array.isArray(value)) {
95
+ return value.slice(0, 50).map((v) => sanitize(v, depth + 1));
96
+ }
97
+
98
+ if (t === 'object') {
99
+ const out = {};
100
+ let n = 0;
101
+ for (const k of Object.keys(value)) {
102
+ if (n++ >= 50) {
103
+ out['…'] = '[字段过多,已截断]';
104
+ break;
105
+ }
106
+ try {
107
+ out[k] = sanitize(value[k], depth + 1);
108
+ } catch {
109
+ out[k] = '[无法序列化]';
110
+ }
111
+ }
112
+ return out;
113
+ }
114
+
115
+ return String(value);
116
+ }
117
+
118
+ /** 进程级上下文,每条日志都会带上 */
119
+ const baseContext = {
120
+ pid: process.pid,
121
+ node: process.version,
122
+ platform: process.platform + '/' + process.arch,
123
+ };
124
+
125
+ /**
126
+ * 记录一条错误事件。
127
+ *
128
+ * @param {string} event 事件名,用点号分层,如 `runtime.install.failed`
129
+ * @param {object} detail 事件细节,任意可序列化对象
130
+ */
131
+ function logError(event, detail) {
132
+ try {
133
+ const dir = resolveLogDir();
134
+ fs.mkdirSync(dir, { recursive: true });
135
+
136
+ const file = path.join(dir, 'error.log');
137
+ rotateIfNeeded(file);
138
+
139
+ const record = {
140
+ time: new Date().toISOString(),
141
+ event: String(event),
142
+ ...baseContext,
143
+ detail: sanitize(detail || {}),
144
+ };
145
+
146
+ fs.appendFileSync(file, JSON.stringify(record) + '\n', 'utf8');
147
+
148
+ // 同时打印一行到 stderr,让开发者在终端也能立刻看到
149
+ process.stderr.write(`[zwcad-mcp][错误] ${record.event}\n`);
150
+ } catch {
151
+ // 日志失败绝不能影响主流程
152
+ }
153
+ }
154
+
155
+ /** 返回当前日志文件路径,供 --doctor 展示 */
156
+ function logFilePath() {
157
+ return path.join(resolveLogDir(), 'error.log');
158
+ }
159
+
160
+ module.exports = { logError, logFilePath, resolveLogDir };
package/lib/proxy.js CHANGED
@@ -30,6 +30,7 @@
30
30
  const fs = require('fs');
31
31
  const path = require('path');
32
32
  const { spawn } = require('child_process');
33
+ const { logError } = require('./errorlog');
33
34
 
34
35
  const PROTOCOL_VERSION = '2024-11-05';
35
36
 
@@ -109,6 +110,13 @@ function createProxy(opts) {
109
110
  let inputBuffer = '';
110
111
  /** 子进程 → 我们的待处理缓冲 */
111
112
  let childBuffer = '';
113
+ /**
114
+ * 子进程 stderr 的尾部。
115
+ *
116
+ * Python 崩溃时异常堆栈在这里,是事后定位问题的关键依据,
117
+ * 所以即使已经转发到自己的 stderr,也要留一份用于写错误日志。
118
+ */
119
+ let stderrTail = '';
112
120
 
113
121
  /** 子进程是否已就绪并接管 */
114
122
  let child = null;
@@ -196,6 +204,7 @@ function createProxy(opts) {
196
204
  runtimeState = 'failed';
197
205
  runtimeError = e && e.message ? e.message : String(e);
198
206
  log('运行时准备失败:' + runtimeError);
207
+ logError('runtime.ready.failed', { error: e });
199
208
  throw e;
200
209
  }
201
210
  })();
@@ -223,7 +232,11 @@ function createProxy(opts) {
223
232
  log('启动 MCP Server…');
224
233
  child = spawn(py, opts.childArgs, { stdio: ['pipe', 'pipe', 'pipe'], env, windowsHide: true });
225
234
 
226
- child.stderr.pipe(process.stderr);
235
+ // 保留 stderr 尾部:Python 若崩溃,异常堆栈在这里,是定位问题的关键
236
+ child.stderr.on('data', (d) => {
237
+ stderrTail = (stderrTail + d.toString('utf8')).slice(-8000);
238
+ process.stderr.write(d);
239
+ });
227
240
 
228
241
  child.stdout.on('data', (chunk) => {
229
242
  childBuffer += chunk.toString('utf8');
@@ -239,10 +252,19 @@ function createProxy(opts) {
239
252
  log('子进程启动失败:' + e.message);
240
253
  runtimeState = 'failed';
241
254
  runtimeError = e.message;
255
+ logError('python.spawn.failed', { error: e, python: py, args: opts.childArgs });
242
256
  });
243
257
 
244
258
  child.on('close', (code) => {
245
259
  log('MCP Server 退出,code=' + code);
260
+ // 非 0 退出往往是 Python 侧崩了,值得留痕;0 是正常结束
261
+ if (code !== 0) {
262
+ logError('python.exited', {
263
+ code,
264
+ initialized: childInitialized,
265
+ stderrTail: stderrTail.slice(-3000),
266
+ });
267
+ }
246
268
  process.exit(code == null ? 1 : code);
247
269
  });
248
270
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zwcad-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "中望CAD(ZWCAD)MCP 服务:用自然语言驱动 ZWCAD 二维平台与中望机械 CAD,完成绘图、标注、图层管理与机械图框/BOM 操作",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -23,6 +23,9 @@
23
23
  "prepare-runtime": "node bridge.js --prepare",
24
24
  "build:connector": "node scripts/build-connector.js",
25
25
  "check:connector": "node scripts/build-connector.js --check",
26
+ "test:errorlog": "node scripts/test-errorlog.js",
27
+ "test:rotation": "node scripts/test-rotation.js",
28
+ "test": "node scripts/test-errorlog.js && node scripts/test-rotation.js",
26
29
  "prepack": "node scripts/build-connector.js --check"
27
30
  },
28
31
  "engines": {