mcp-log-query-server 3.5.3 → 3.6.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/logger.js CHANGED
@@ -1,9 +1,12 @@
1
1
  /**
2
- * 轻量日志模块:console.error + 本地文件追加
2
+ * 轻量日志模块:本地文件追加(默认不走 stderr)
3
3
  *
4
- * 为什么需要文件日志?
5
- * Windsurf 不会把 MCP server stderr 落盘到 %APPDATA%\Windsurf\logs,
6
- * 只在 Output 面板实时显示。一旦进程重启、面板关闭,信息就丢了。
4
+ * ⚠️ 重要背景(3.6.1):
5
+ * Windsurf 根本没有 MCP 专属的 Output 频道,从不读取子进程的 stderr
6
+ * Node 默认 stderr pipe buffer ~64KB,满了后 `console.error` 会**同步阻塞 event loop**
7
+ * → 整个 MCP 进程冻结,新请求来了不处理,已发出的 fetch 不推进,cancel signal 也接收不到。
8
+ *
9
+ * 因此默认只写文件,stderr 仅在显式要求时开启(用于单独调试,MCP server 场景禁用)。
7
10
  *
8
11
  * 用法:
9
12
  * import { log } from './logger.js';
@@ -16,7 +19,8 @@
16
19
  * 环境变量:
17
20
  * - MCP_LOG_FILE: 自定义日志文件路径(默认 <tmpdir>/mcp-log-query.log)
18
21
  * - MCP_LOG_MAX_BYTES: 单文件最大字节数(默认 10MB,超过则轮转到 .1)
19
- * - MCP_LOG_DISABLE: 设为 '1' 则禁用文件日志(只走 stderr)
22
+ * - MCP_LOG_DISABLE: 设为 '1' 则完全禁用日志(文件+stderr 都不写)
23
+ * - MCP_LOG_STDERR: 设为 '1' 时**同时**写 stderr(独立调试用,MCP 子进程模式下不要开)
20
24
  */
21
25
 
22
26
  import fs from 'node:fs';
@@ -26,6 +30,8 @@ import path from 'node:path';
26
30
  const LOG_FILE = process.env.MCP_LOG_FILE || path.join(os.tmpdir(), 'mcp-log-query.log');
27
31
  const MAX_BYTES = parseInt(process.env.MCP_LOG_MAX_BYTES || `${10 * 1024 * 1024}`, 10);
28
32
  const DISABLED = process.env.MCP_LOG_DISABLE === '1';
33
+ // 默认不走 stderr(MCP 子进程下会被 host 无限 backpressure 阻塞 event loop)
34
+ const WRITE_STDERR = process.env.MCP_LOG_STDERR === '1';
29
35
 
30
36
  // 懒初始化:首次写入时再检查目录
31
37
  let initialized = false;
@@ -41,8 +47,8 @@ function ensureInit() {
41
47
  LOG_FILE,
42
48
  `\n========== [${new Date().toISOString()}] MCP log-query 进程启动 pid=${process.pid} ==========\n`
43
49
  );
44
- } catch (err) {
45
- console.error('[logger] 初始化文件日志失败,仅使用 stderr:', err.message);
50
+ } catch {
51
+ // 文件初始化失败,静默(同 log 函数的考虑)
46
52
  }
47
53
  }
48
54
 
@@ -59,13 +65,14 @@ function rotateIfNeeded() {
59
65
  }
60
66
 
61
67
  /**
62
- * 写一条日志:stderr + 可选本地文件
68
+ * 写一条日志:默认仅本地文件;MCP_LOG_STDERR=1 时同时写 stderr
69
+ *
70
+ * ⚠️ 重要:MCP 子进程下 stderr 管道会被 host 无限 backpressure 阻塞 event loop,
71
+ * 所以默认禁用 stderr 输出。只在独立调试(非 MCP 模式)时开启。
72
+ *
63
73
  * @param {string} msg - 日志内容(不需要带时间戳,本函数自动加)
64
74
  */
65
75
  export function log(msg) {
66
- // stderr 永远写,兼容 Windsurf Output 面板实时查看
67
- console.error(msg);
68
-
69
76
  if (DISABLED) return;
70
77
 
71
78
  ensureInit();
@@ -74,12 +81,15 @@ export function log(msg) {
74
81
  try {
75
82
  rotateIfNeeded();
76
83
  fs.appendFileSync(LOG_FILE, line);
77
- } catch (err) {
78
- // 文件写失败不能影响主流程,但 stderr 提示一次
79
- if (!log._warnedFileFail) {
80
- log._warnedFileFail = true;
81
- console.error('[logger] 写日志文件失败(后续不再提示):', err.message);
82
- }
84
+ } catch {
85
+ // 文件写失败静默忽略:不能为了日志去打 stderr(会阻塞 event loop)
86
+ // 也不缓存 warning;调试请检查磁盘 / 权限
87
+ }
88
+
89
+ // 可选 stderr 输出(独立调试用)
90
+ if (WRITE_STDERR) {
91
+ // 直接 write,失败忽略;不用 console.error 以免同步阻塞
92
+ try { process.stderr.write(msg + '\n'); } catch {}
83
93
  }
84
94
  }
85
95
 
package/loki-client.js CHANGED
@@ -67,14 +67,25 @@ export async function queryLoki(envName, expr, options = {}) {
67
67
 
68
68
  log(`[Loki] 查询: env=${envName}, expr=${expr}`);
69
69
 
70
- // 带超时的 fetch —— AbortController 覆盖 fetch + body 读取全过程
71
- // 之前的 bug:fetch resolve 后立刻 clearTimeout,导致 resp.json() body 卡住时无保护
70
+ // 带超时 + 用户 cancel 的 fetch —— AbortController 覆盖 fetch + body 读取全过程
71
+ // 用户 signal (options.signal) 一旦 abort,立即中断;内部 30s timer 也触发 abort
72
72
  const controller = new AbortController();
73
73
  const timer = setTimeout(() => {
74
74
  log(`[Loki] ⏱ 超时 ${LOKI_FETCH_TIMEOUT}ms,主动 abort: env=${envName}, expr=${expr.substring(0, 80)}`);
75
- controller.abort();
75
+ controller.abort(new Error('TIMEOUT'));
76
76
  }, LOKI_FETCH_TIMEOUT);
77
77
 
78
+ // 用户 cancel 穿透
79
+ const userSignal = options.signal;
80
+ const onUserAbort = () => {
81
+ log(`[Loki] ⊗ 用户 cancel,主动 abort: env=${envName}`);
82
+ controller.abort(new Error('USER_CANCELLED'));
83
+ };
84
+ if (userSignal) {
85
+ if (userSignal.aborted) onUserAbort();
86
+ else userSignal.addEventListener('abort', onUserAbort, { once: true });
87
+ }
88
+
78
89
  try {
79
90
  const resp = await fetch(url, {
80
91
  method: 'POST',
@@ -94,11 +105,18 @@ export async function queryLoki(envName, expr, options = {}) {
94
105
  return parseLokiResponse(data);
95
106
  } catch (e) {
96
107
  if (e.name === 'AbortError') {
108
+ // 区分原因:用户 cancel vs 超时
109
+ if (userSignal && userSignal.aborted) {
110
+ const err = new Error('CANCELLED');
111
+ err.name = 'AbortError';
112
+ throw err;
113
+ }
97
114
  throw new Error(`Loki 查询超时(${LOKI_FETCH_TIMEOUT}ms)`);
98
115
  }
99
116
  throw e;
100
117
  } finally {
101
118
  clearTimeout(timer);
119
+ if (userSignal) userSignal.removeEventListener('abort', onUserAbort);
102
120
  }
103
121
  }
104
122
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-log-query-server",
3
- "version": "3.5.3",
3
+ "version": "3.6.1",
4
4
  "description": "MCP Server for querying server logs via SSH jump host and Grafana Loki API",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/ssh-client.js CHANGED
@@ -35,9 +35,13 @@ const _sshSem = {
35
35
  /**
36
36
  * 获取 SSH 信号量槽位
37
37
  * @param {number} [timeoutMs] - 排队超时(默认 60s),防止无限等
38
+ * @param {AbortSignal} [signal] - 用户 cancel 信号,abort 时立即从队列移除并 reject
38
39
  * @returns {Promise<void>}
39
40
  */
40
- function sshAcquire(timeoutMs = SSH_ACQUIRE_TIMEOUT) {
41
+ function sshAcquire(timeoutMs = SSH_ACQUIRE_TIMEOUT, signal) {
42
+ if (signal && signal.aborted) {
43
+ return Promise.reject(new Error('CANCELLED before SSH acquire'));
44
+ }
41
45
  if (_sshSem.active < _sshSem.max) {
42
46
  _sshSem.active++;
43
47
  log(`[SSH-Sem] acquire 直接通过 (active=${_sshSem.active}/${_sshSem.max}, queue=${_sshSem.queue.length})`);
@@ -49,10 +53,15 @@ function sshAcquire(timeoutMs = SSH_ACQUIRE_TIMEOUT) {
49
53
  log(`[SSH-Sem] acquire 进入排队 (active=${_sshSem.active}/${_sshSem.max}, queue=${_sshSem.queue.length + 1}, timeout=${timeoutMs}ms)`);
50
54
  return new Promise((resolve, reject) => {
51
55
  let settled = false;
56
+ let onAbort;
57
+ const cleanup = () => {
58
+ clearTimeout(timer);
59
+ if (signal && onAbort) signal.removeEventListener('abort', onAbort);
60
+ };
52
61
  const enterQueue = () => {
53
- if (settled) return; // 已超时,放弃并立刻腾位给下一个
62
+ if (settled) return; // 已超时/已取消,放弃并立刻腾位给下一个
54
63
  settled = true;
55
- clearTimeout(timer);
64
+ cleanup();
56
65
  _sshSem.active++;
57
66
  log(`[SSH-Sem] acquire 出队获得槽位 (active=${_sshSem.active}/${_sshSem.max}, queue=${_sshSem.queue.length})`);
58
67
  resolve();
@@ -61,12 +70,23 @@ function sshAcquire(timeoutMs = SSH_ACQUIRE_TIMEOUT) {
61
70
  const timer = setTimeout(() => {
62
71
  if (settled) return;
63
72
  settled = true;
64
- // 从队列里移除自己的 entry
73
+ cleanup();
65
74
  const idx = _sshSem.queue.indexOf(enterQueue);
66
75
  if (idx >= 0) _sshSem.queue.splice(idx, 1);
67
76
  log(`[SSH-Sem] acquire 排队超时 (${timeoutMs}ms, active=${_sshSem.active}/${_sshSem.max}, queue=${_sshSem.queue.length})`);
68
77
  reject(new Error(`SSH 排队等待超时 (${timeoutMs}ms):前面请求卡住,或并发过高。可调整 SSH_MAX_CONCURRENT / SSH_ACQUIRE_TIMEOUT`));
69
78
  }, timeoutMs);
79
+ // 用户 cancel:立即从队列移除
80
+ onAbort = () => {
81
+ if (settled) return;
82
+ settled = true;
83
+ cleanup();
84
+ const idx = _sshSem.queue.indexOf(enterQueue);
85
+ if (idx >= 0) _sshSem.queue.splice(idx, 1);
86
+ log(`[SSH-Sem] acquire 用户 cancel,从队列移除 (active=${_sshSem.active}/${_sshSem.max}, queue=${_sshSem.queue.length})`);
87
+ reject(new Error('CANCELLED in SSH queue'));
88
+ };
89
+ if (signal) signal.addEventListener('abort', onAbort, { once: true });
70
90
  });
71
91
  }
72
92
 
@@ -87,8 +107,9 @@ function sshRelease() {
87
107
  */
88
108
  export async function queryLog(service, command, options = {}) {
89
109
  const timeout = options.timeout || DEFAULTS.timeout;
110
+ const signal = options.signal;
90
111
 
91
- await sshAcquire();
112
+ await sshAcquire(undefined, signal);
92
113
  try {
93
114
  return await new Promise((resolve, reject) => {
94
115
  const conn = new Client();
@@ -108,6 +129,20 @@ export async function queryLog(service, command, options = {}) {
108
129
  }
109
130
  }, timeout);
110
131
 
132
+ // 用户 cancel:立即 destroy 连接
133
+ const onAbort = () => {
134
+ if (settled) return;
135
+ settled = true;
136
+ clearTimeout(timeoutId);
137
+ log(`[SSH] ⊗ 用户 cancel,destroy 连接 (${service.name})`);
138
+ try { conn.destroy(); } catch {};
139
+ reject(new Error('CANCELLED during SSH'));
140
+ };
141
+ if (signal) {
142
+ if (signal.aborted) { onAbort(); return; }
143
+ signal.addEventListener('abort', onAbort, { once: true });
144
+ }
145
+
111
146
  conn.on('ready', () => {
112
147
  conn.shell({ term: 'xterm', rows: 24, cols: 500 }, (err, stream) => {
113
148
  if (err) {
@@ -267,8 +302,9 @@ export async function testConnection() {
267
302
  */
268
303
  export async function executeKubectl(kubectlCommand, options = {}) {
269
304
  const timeout = options.timeout || DEFAULTS.timeout;
305
+ const signal = options.signal;
270
306
 
271
- await sshAcquire();
307
+ await sshAcquire(undefined, signal);
272
308
  try {
273
309
  return await new Promise((resolve, reject) => {
274
310
  const conn = new Client();
@@ -288,6 +324,20 @@ export async function executeKubectl(kubectlCommand, options = {}) {
288
324
  }
289
325
  }, timeout);
290
326
 
327
+ // 用户 cancel:立即 destroy 连接
328
+ const onAbort = () => {
329
+ if (settled) return;
330
+ settled = true;
331
+ clearTimeout(timeoutId);
332
+ log(`[SSH] ⊗ 用户 cancel,destroy 连接 (kubectl: ${kubectlCommand.substring(0, 50)}...)`);
333
+ try { conn.destroy(); } catch {};
334
+ reject(new Error('CANCELLED during kubectl'));
335
+ };
336
+ if (signal) {
337
+ if (signal.aborted) { onAbort(); return; }
338
+ signal.addEventListener('abort', onAbort, { once: true });
339
+ }
340
+
291
341
  conn.on('ready', () => {
292
342
  conn.shell({ term: 'xterm', rows: 24, cols: 500 }, (err, stream) => {
293
343
  if (err) {