mcp-log-query-server 3.5.1 → 3.5.2
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/index.js +24 -10
- package/logger.js +91 -0
- package/package.json +2 -1
- package/ssh-client.js +6 -5
package/index.js
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
|
|
28
28
|
import { queryLog, testConnection, executeKubectl } from './ssh-client.js';
|
|
29
29
|
import { findService, getAllServices, DEFAULTS, DEFAULT_NAMESPACE, SERVICES, NAMESPACES, detectContextFromPath, isLokiEnv, resolveLokiEnvName, LOKI_ENVIRONMENTS } from './config.js';
|
|
30
|
+
import { log, getLogFilePath } from './logger.js';
|
|
30
31
|
import {
|
|
31
32
|
queryLoki, queryLokiAutoRange, parseTimeStr,
|
|
32
33
|
extractTraceIds, parseServiceFromFilename, groupLogsByService,
|
|
@@ -47,16 +48,26 @@ function withTimeout(promise, ms, label) {
|
|
|
47
48
|
]);
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
// 安全序列化工具参数(截断超长值,容错循环引用)
|
|
52
|
+
function safeStringify(obj, maxLen = 200) {
|
|
53
|
+
try {
|
|
54
|
+
const s = JSON.stringify(obj);
|
|
55
|
+
return s.length > maxLen ? s.slice(0, maxLen) + '...' : s;
|
|
56
|
+
} catch {
|
|
57
|
+
return '<unserializable>';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
50
61
|
// 进程级安全网:只记录日志,不退出进程
|
|
51
62
|
// 退出会导致 stdio 断开,整个 MCP 不可用直到 IDE 重启;单次请求错误不应拖死服务
|
|
52
|
-
process.on('unhandledRejection', (err) =>
|
|
53
|
-
process.on('uncaughtException', (err) =>
|
|
63
|
+
process.on('unhandledRejection', (err) => log(`[unhandledRejection] ${err && err.stack || err}`));
|
|
64
|
+
process.on('uncaughtException', (err) => log(`[uncaughtException] ${err && err.stack || err}`));
|
|
54
65
|
|
|
55
66
|
// 创建 MCP Server
|
|
56
67
|
const server = new Server(
|
|
57
68
|
{
|
|
58
69
|
name: 'mcp-log-query',
|
|
59
|
-
version: '3.5.
|
|
70
|
+
version: '3.5.2',
|
|
60
71
|
},
|
|
61
72
|
{
|
|
62
73
|
capabilities: {
|
|
@@ -339,11 +350,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
339
350
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
340
351
|
const { name, arguments: args } = request.params;
|
|
341
352
|
const startTime = Date.now();
|
|
353
|
+
log(`[Tool] → ${name} start args=${safeStringify(args)}`);
|
|
342
354
|
|
|
343
355
|
// 看门狗:仅记录长时间未完成的请求,不再退出进程
|
|
344
356
|
// withTimeout(REQUEST_TIMEOUT) 已经保证单次请求超时会抛错
|
|
345
357
|
const watchdog = setTimeout(() => {
|
|
346
|
-
|
|
358
|
+
log(`[Watchdog] ${name} 仍在运行超过 ${WATCHDOG_WARN_TIMEOUT}ms(仅记录,不退出进程)`);
|
|
347
359
|
}, WATCHDOG_WARN_TIMEOUT);
|
|
348
360
|
watchdog.unref();
|
|
349
361
|
|
|
@@ -354,11 +366,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
354
366
|
name
|
|
355
367
|
);
|
|
356
368
|
clearTimeout(watchdog);
|
|
357
|
-
|
|
369
|
+
log(`[Tool] ✓ ${name} done ${Date.now() - startTime}ms`);
|
|
358
370
|
return result;
|
|
359
371
|
} catch (error) {
|
|
360
372
|
clearTimeout(watchdog);
|
|
361
|
-
|
|
373
|
+
log(`[Tool] ✗ ${name} FAIL ${Date.now() - startTime}ms: ${error.message}`);
|
|
362
374
|
return {
|
|
363
375
|
content: [{ type: 'text', text: `## 执行错误\n\n❌ ${error.message}` }],
|
|
364
376
|
isError: true
|
|
@@ -834,20 +846,22 @@ process.on('SIGTERM', gracefulShutdown);
|
|
|
834
846
|
async function main() {
|
|
835
847
|
// 对齐 auggie: 监听 stdin end/close,宿主进程断开时优雅关闭
|
|
836
848
|
process.stdin.on('end', () => {
|
|
837
|
-
|
|
849
|
+
log('[MCP] stdin end, initiating graceful shutdown');
|
|
838
850
|
gracefulShutdown();
|
|
839
851
|
});
|
|
840
852
|
process.stdin.on('close', () => {
|
|
841
|
-
|
|
853
|
+
log('[MCP] stdin close, initiating graceful shutdown');
|
|
842
854
|
gracefulShutdown();
|
|
843
855
|
});
|
|
844
856
|
|
|
845
857
|
const transport = new StdioServerTransport();
|
|
846
858
|
await server.connect(transport);
|
|
847
|
-
|
|
859
|
+
const logPath = getLogFilePath();
|
|
860
|
+
log(`[MCP] Log Query Server v3.5.2 已启动 (超时保护 + SSH 并发限制 + 排队超时 + 文件日志 + 进程不自杀)`);
|
|
861
|
+
if (logPath) log(`[MCP] 本地日志文件: ${logPath}`);
|
|
848
862
|
}
|
|
849
863
|
|
|
850
864
|
main().catch((error) => {
|
|
851
|
-
|
|
865
|
+
log(`[MCP] 启动失败: ${error && error.stack || error}`);
|
|
852
866
|
process.exit(1);
|
|
853
867
|
});
|
package/logger.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 轻量日志模块:console.error + 本地文件追加
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要文件日志?
|
|
5
|
+
* Windsurf 不会把 MCP server 的 stderr 落盘到 %APPDATA%\Windsurf\logs,
|
|
6
|
+
* 只在 Output 面板实时显示。一旦进程重启、面板关闭,信息就丢了。
|
|
7
|
+
*
|
|
8
|
+
* 用法:
|
|
9
|
+
* import { log } from './logger.js';
|
|
10
|
+
* log('[SSH-Sem] acquire ...');
|
|
11
|
+
*
|
|
12
|
+
* 诊断卡住时:
|
|
13
|
+
* Get-Content $env:TEMP\mcp-log-query.log -Tail 20 -Wait (Windows)
|
|
14
|
+
* tail -F /tmp/mcp-log-query.log (Linux/Mac)
|
|
15
|
+
*
|
|
16
|
+
* 环境变量:
|
|
17
|
+
* - MCP_LOG_FILE: 自定义日志文件路径(默认 <tmpdir>/mcp-log-query.log)
|
|
18
|
+
* - MCP_LOG_MAX_BYTES: 单文件最大字节数(默认 10MB,超过则轮转到 .1)
|
|
19
|
+
* - MCP_LOG_DISABLE: 设为 '1' 则禁用文件日志(只走 stderr)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
|
|
26
|
+
const LOG_FILE = process.env.MCP_LOG_FILE || path.join(os.tmpdir(), 'mcp-log-query.log');
|
|
27
|
+
const MAX_BYTES = parseInt(process.env.MCP_LOG_MAX_BYTES || `${10 * 1024 * 1024}`, 10);
|
|
28
|
+
const DISABLED = process.env.MCP_LOG_DISABLE === '1';
|
|
29
|
+
|
|
30
|
+
// 懒初始化:首次写入时再检查目录
|
|
31
|
+
let initialized = false;
|
|
32
|
+
|
|
33
|
+
function ensureInit() {
|
|
34
|
+
if (initialized) return;
|
|
35
|
+
initialized = true;
|
|
36
|
+
if (DISABLED) return;
|
|
37
|
+
try {
|
|
38
|
+
fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
|
|
39
|
+
// 启动时打一条 banner,方便判断是不是新进程
|
|
40
|
+
fs.appendFileSync(
|
|
41
|
+
LOG_FILE,
|
|
42
|
+
`\n========== [${new Date().toISOString()}] MCP log-query 进程启动 pid=${process.pid} ==========\n`
|
|
43
|
+
);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.error('[logger] 初始化文件日志失败,仅使用 stderr:', err.message);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function rotateIfNeeded() {
|
|
50
|
+
try {
|
|
51
|
+
const st = fs.statSync(LOG_FILE);
|
|
52
|
+
if (st.size < MAX_BYTES) return;
|
|
53
|
+
const backup = `${LOG_FILE}.1`;
|
|
54
|
+
try { fs.rmSync(backup, { force: true }); } catch {}
|
|
55
|
+
try { fs.renameSync(LOG_FILE, backup); } catch {}
|
|
56
|
+
} catch {
|
|
57
|
+
// 文件不存在等错误忽略
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 写一条日志:stderr + 可选本地文件
|
|
63
|
+
* @param {string} msg - 日志内容(不需要带时间戳,本函数自动加)
|
|
64
|
+
*/
|
|
65
|
+
export function log(msg) {
|
|
66
|
+
// stderr 永远写,兼容 Windsurf Output 面板实时查看
|
|
67
|
+
console.error(msg);
|
|
68
|
+
|
|
69
|
+
if (DISABLED) return;
|
|
70
|
+
|
|
71
|
+
ensureInit();
|
|
72
|
+
|
|
73
|
+
const line = `[${new Date().toISOString()}] ${msg}\n`;
|
|
74
|
+
try {
|
|
75
|
+
rotateIfNeeded();
|
|
76
|
+
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
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 获取当前日志文件路径(方便 MCP 工具返回给调用方)
|
|
88
|
+
*/
|
|
89
|
+
export function getLogFilePath() {
|
|
90
|
+
return DISABLED ? null : LOG_FILE;
|
|
91
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-log-query-server",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.2",
|
|
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",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"config.js",
|
|
13
13
|
"ssh-client.js",
|
|
14
14
|
"loki-client.js",
|
|
15
|
+
"logger.js",
|
|
15
16
|
"server-sse.js",
|
|
16
17
|
"README.md"
|
|
17
18
|
],
|
package/ssh-client.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { Client } from 'ssh2';
|
|
17
17
|
import { JUMP_HOST, K8S_SERVER, DEFAULTS } from './config.js';
|
|
18
|
+
import { log } from './logger.js';
|
|
18
19
|
|
|
19
20
|
// ============================================================
|
|
20
21
|
// 并发信号量:防止同时打开过多堡垒机会话被踢
|
|
@@ -39,13 +40,13 @@ const _sshSem = {
|
|
|
39
40
|
function sshAcquire(timeoutMs = SSH_ACQUIRE_TIMEOUT) {
|
|
40
41
|
if (_sshSem.active < _sshSem.max) {
|
|
41
42
|
_sshSem.active++;
|
|
42
|
-
|
|
43
|
+
log(`[SSH-Sem] acquire 直接通过 (active=${_sshSem.active}/${_sshSem.max}, queue=${_sshSem.queue.length})`);
|
|
43
44
|
return Promise.resolve();
|
|
44
45
|
}
|
|
45
46
|
if (_sshSem.queue.length >= _sshSem.queueMax) {
|
|
46
47
|
return Promise.reject(new Error(`SSH 并发队列已满(>${_sshSem.queueMax}),请稍后重试`));
|
|
47
48
|
}
|
|
48
|
-
|
|
49
|
+
log(`[SSH-Sem] acquire 进入排队 (active=${_sshSem.active}/${_sshSem.max}, queue=${_sshSem.queue.length + 1}, timeout=${timeoutMs}ms)`);
|
|
49
50
|
return new Promise((resolve, reject) => {
|
|
50
51
|
let settled = false;
|
|
51
52
|
const enterQueue = () => {
|
|
@@ -53,7 +54,7 @@ function sshAcquire(timeoutMs = SSH_ACQUIRE_TIMEOUT) {
|
|
|
53
54
|
settled = true;
|
|
54
55
|
clearTimeout(timer);
|
|
55
56
|
_sshSem.active++;
|
|
56
|
-
|
|
57
|
+
log(`[SSH-Sem] acquire 出队获得槽位 (active=${_sshSem.active}/${_sshSem.max}, queue=${_sshSem.queue.length})`);
|
|
57
58
|
resolve();
|
|
58
59
|
};
|
|
59
60
|
_sshSem.queue.push(enterQueue);
|
|
@@ -63,7 +64,7 @@ function sshAcquire(timeoutMs = SSH_ACQUIRE_TIMEOUT) {
|
|
|
63
64
|
// 从队列里移除自己的 entry
|
|
64
65
|
const idx = _sshSem.queue.indexOf(enterQueue);
|
|
65
66
|
if (idx >= 0) _sshSem.queue.splice(idx, 1);
|
|
66
|
-
|
|
67
|
+
log(`[SSH-Sem] acquire 排队超时 (${timeoutMs}ms, active=${_sshSem.active}/${_sshSem.max}, queue=${_sshSem.queue.length})`);
|
|
67
68
|
reject(new Error(`SSH 排队等待超时 (${timeoutMs}ms):前面请求卡住,或并发过高。可调整 SSH_MAX_CONCURRENT / SSH_ACQUIRE_TIMEOUT`));
|
|
68
69
|
}, timeoutMs);
|
|
69
70
|
});
|
|
@@ -74,7 +75,7 @@ function sshRelease() {
|
|
|
74
75
|
// 超时的 entry 已在 timer 里从 queue 移除,这里 shift 到的都是活的
|
|
75
76
|
const next = _sshSem.queue.shift();
|
|
76
77
|
if (next) next();
|
|
77
|
-
|
|
78
|
+
log(`[SSH-Sem] release (active=${_sshSem.active}/${_sshSem.max}, queue=${_sshSem.queue.length})`);
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
/**
|